From ac7a3abda96e0453dec71855cd7c078a116fdae4 Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Thu, 11 Jun 2026 15:24:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?config(git):=20=E5=BC=95=E5=85=A5develop?= =?UTF-8?q?=E9=95=BF=E6=9C=9F=E5=BC=80=E5=8F=91=E5=88=86=E6=94=AF=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=92=8Cgit=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/agents/game-designer.md | 28 ++ .claude/agents/game-dev.md | 36 +++ .claude/agents/game-pm.md | 30 +++ .claude/refs/GIT-GUIDE-LITE.md | 57 +++- .claude/refs/GIT-GUIDE.md | 96 +++---- .claude/scripts/git-workflow.sh | 326 +++++++++++++++++++++++ .claude/skills/gitea-collab/SKILL.md | 38 ++- .claude/skills/gitea-collab/gitea-api.js | 21 ++ .claude/skills/gitea-collab/gitea.js | 83 ++++++ .claude/skills/gitea-pm/SKILL.md | 31 ++- .claude/skills/gitea-pm/gitea-api.js | 21 ++ .claude/skills/gitea-pm/gitea.js | 86 ++++++ 12 files changed, 781 insertions(+), 72 deletions(-) create mode 100644 .claude/scripts/git-workflow.sh diff --git a/.claude/agents/game-designer.md b/.claude/agents/game-designer.md index 2adebff..b0701f6 100644 --- a/.claude/agents/game-designer.md +++ b/.claude/agents/game-designer.md @@ -243,6 +243,34 @@ GameConfig/ # 数值配置表 --- +## Git 工作流(develop 分支模型) + +项目使用 `develop` 长期开发分支。所有 agent 在 develop 上工作,迭代结束时由 PM 发起 PR 合并回 main。 + +### 开始工作 +```bash +bash .claude/scripts/git-workflow.sh ready +``` + +### 提交文档 +```bash +bash .claude/scripts/git-workflow.sh commit docs "中文描述" +``` +示例:`bash .claude/scripts/git-workflow.sh commit docs design "交互系统执行案"` + +### 推送 +```bash +bash .claude/scripts/git-workflow.sh push +``` + +### 完成工作后 +1. 推送文档到 develop +2. 通过 gitea-collab 在对应 Issue 下评论汇报进度 +3. 更新 Issue 标签为 `status:in-review` +4. 启动 `game-reviewer` 子代理 + +--- + ## 审查交接 完成工作并发布 Issue 评论后,启动审查流程: diff --git a/.claude/agents/game-dev.md b/.claude/agents/game-dev.md index dead7cd..3110749 100644 --- a/.claude/agents/game-dev.md +++ b/.claude/agents/game-dev.md @@ -221,6 +221,42 @@ skills: --- +## Git 工作流(develop 分支模型) + +项目使用 `develop` 长期开发分支。所有 agent 在 develop 上工作,迭代结束时由 PM 发起 PR 合并回 main。 + +### 开始开发 +```bash +bash .claude/scripts/git-workflow.sh ready +``` +确认在 develop 分支上且代码最新。 + +### 提交代码 +```bash +bash .claude/scripts/git-workflow.sh commit "中文描述" +``` +- ⚠️ 只提交自己修改的文件,避免提交其他 agent 的 WIP +- 蓝图(.uasset)和 C++ 分开提交 +- commit message subject ≤ 50 字符 + +### 推送 +```bash +bash .claude/scripts/git-workflow.sh push +``` + +### 完成开发后 +1. 推送代码到 develop +2. 通过 gitea-collab 在对应 Issue 下评论汇报进度 +3. 更新 Issue 标签为 `status:in-review` +4. 启动 `game-reviewer` 子代理 + +### 开发中发现 main 有更新 +```bash +bash .claude/scripts/git-workflow.sh catchup +``` + +--- + ## 代码规范 - C++ 和蓝图遵循 UE5 编码规范 diff --git a/.claude/agents/game-pm.md b/.claude/agents/game-pm.md index 9313f5c..5653bd9 100644 --- a/.claude/agents/game-pm.md +++ b/.claude/agents/game-pm.md @@ -103,6 +103,36 @@ skills: --- +## Git 工作流(develop 分支模型) + +项目使用 `develop` 长期开发分支,所有 agent 在 develop 上工作。 + +### PM 日常 +```bash +# 会话开始时同步 +bash .claude/scripts/git-workflow.sh ready +``` + +### 迭代完成 → 发布 +当所有 Issue 都 review:pass 且用户确认发布时: + +```bash +# 创建 develop → main 的 PR +bash .claude/scripts/git-workflow.sh release "迭代名称" +# 或手动: +# node .claude/skills/gitea-pm/gitea.js --role pm pr create --base main --head develop --title "标题" --body "描述" +``` + +通知用户在 Gitea 审查并合并 PR。 + +### 用户合并后同步 +```bash +bash .claude/scripts/git-workflow.sh post-release +``` +自动同步 main 并将 merge commit 同步回 develop。 + +--- + ## 决策框架 ### 你可以自主决定: diff --git a/.claude/refs/GIT-GUIDE-LITE.md b/.claude/refs/GIT-GUIDE-LITE.md index 65b0d2d..0d7441f 100644 --- a/.claude/refs/GIT-GUIDE-LITE.md +++ b/.claude/refs/GIT-GUIDE-LITE.md @@ -6,23 +6,30 @@ ## 分支策略 -**轻量 Feature Branch** — 单人开发,`main` 永远可玩可编译。 +**develop 长期开发分支模型** — 单人开发 + 多 Agent 协作。 + +``` +main ─────────── 永远可玩可编译,受保护,禁止直接推送 + │ + │ PR (Gitea) ← 里程碑/迭代结束时,PM 发起 + │ + └── develop ──── 长期分支,所有 agent 在此工作 +``` + +| 分支 | 生命期 | 说明 | +|---|---|---| +| `main` | 永久 | 稳定版本,受保护 | +| `develop` | 永久 | 日常开发分支,所有 agent 共用 | + +**辅助分支(可选,用于实验):** | 分支 | 前缀 | 生命期 | 说明 | |---|---|---|---| -| 主线 | `main` | 永久 | 稳定版本,不直接在上面开发 | -| 功能 | `feature/` | 合并后删 | 新系统、新功能 | -| 修复 | `fix/` | 合并后删 | Bug 修复 | | 原型 | `prototype/` | 实验完即删 | 技术验证,随便写 | | 发布 | `release/` | 永久 | 从 main 切出,版本标记 | -**命名规则:** `<类型>/<小写连字符描述>` -``` -feature/player-movement fix/camera-clipping release/v0.1 -``` - **流程:** -1. 从 `main` 拉分支 → 2. 开发并提交 → 3. 测试通过后合并回 `main` → 4. 删除分支 +1. 所有开发在 `develop` 上进行 → 2. 迭代完成时创建 PR → 3. 审查通过后合并回 `main` --- @@ -100,3 +107,33 @@ Host gitea micecola.top **内网地址**(家里用):`ssh://sc-gitea@192.168.1.22:2223/starmen1265/ChildHood.git` 完整规范见 → [GIT-GUIDE.md](GIT-GUIDE.md) + +--- + +## 自动化工作流 + +`git-workflow.sh` 脚本封装了常用 git 操作: + +```bash +# 开始工作:确保在 develop 上且最新 +bash .claude/scripts/git-workflow.sh ready + +# 提交修改 +bash .claude/scripts/git-workflow.sh commit "中文描述" + +# 推送到远程 +bash .claude/scripts/git-workflow.sh push + +# 查看状态 +bash .claude/scripts/git-workflow.sh status +``` + +### PM 发布流程 + +```bash +# 创建 develop → main PR +bash .claude/scripts/git-workflow.sh release "迭代名称" + +# 用户在 Gitea 合并后同步 +bash .claude/scripts/git-workflow.sh post-release +``` diff --git a/.claude/refs/GIT-GUIDE.md b/.claude/refs/GIT-GUIDE.md index 214ef9c..0c93dd2 100644 --- a/.claude/refs/GIT-GUIDE.md +++ b/.claude/refs/GIT-GUIDE.md @@ -7,81 +7,57 @@ ## 一、分支策略 -采用**轻量级 Feature Branch 模型**,避免企业级复杂流程,保持 `main` 分支随时可玩。 +采用 **develop 长期开发分支模型**,适合单人开发 + 多 AI Agent 协作场景。所有 agent 在 `develop` 上工作,迭代结束时通过 PR 合并回 `main`。 ### 1.1 分支总览 ``` -main ──────────────────────────────────────── 稳定主线 - │ - ├── feature/player-movement ───→ 合并后删除 - ├── feature/inventory-system ──→ 合并后删除 - ├── fix/camera-clipping ───────→ 合并后删除 - ├── prototype/procedural-gen ──→ 实验完即删 - └── release/v0.1 ──────────────→ 永久保留 +main ──────────── 永远可玩可编译,受保护,禁止直接推送 + │ + │ PR (Gitea) ← 里程碑/迭代结束时,PM 发起 + │ + └── develop ──── 长期分支,所有 agent 的日常工作分支 + │ + ├── 程序1 提交代码 + ├── 策划 提交文档 + ├── 程序2 提交代码 + └── 迭代就绪 → PR develop→main ``` ### 1.2 分支类型 -| 分支 | 前缀 | 用途 | 合并目标 | 生命周期 | -|---|---|---|---|---| -| 主线 | `main` | 稳定可玩版本,每次提交都能编译运行 | — | 永久 | -| 功能 | `feature/` | 新系统、新功能开发 | `main` | 完成后删除 | -| 修复 | `fix/` | Bug 修复 | `main` | 完成后删除 | -| 原型 | `prototype/` | 技术验证、玩法实验 | 合并或丢弃 | 实验完即删 | -| 发布 | `release/` | 版本发布标记,只从 `main` 切出 | — | 永久保留 | +| 分支 | 用途 | 生命周期 | +|---|---|---| +| `main` | 稳定可玩版本,受保护 | 永久 | +| `develop` | 日常开发,所有 agent 共用 | 永久 | +| `prototype/` | 技术验证、玩法实验 | 实验完即删 | +| `release/` | 版本发布标记,只从 main 切出 | 永久保留 | -### 1.3 命名规范 - -格式:`<类型>/<简短描述>`,全小写,用连字符分隔单词。 +### 1.3 工作流程 ``` -# ✅ 好的命名 -feature/player-movement -feature/inventory-system -feature/dialogue-tree -fix/camera-clipping-through-walls -fix/enemy-ai-stuck-on-navmesh -prototype/procedural-dungeon -release/v0.1 +1. 开始工作(agent 自动执行) + bash .claude/scripts/git-workflow.sh ready -# ❌ 不好的命名 -my-new-feature -fix-bug -test -feature/AddPlayerMovement +2. 开发并提交 + bash .claude/scripts/git-workflow.sh commit "描述" + +3. 推送到远程 + bash .claude/scripts/git-workflow.sh push + +4. 迭代完成,PM 创建 PR + bash .claude/scripts/git-workflow.sh release "迭代名称" + +5. 用户在 Gitea 合并后同步 + bash .claude/scripts/git-workflow.sh post-release ``` -### 1.4 工作流程 +### 1.4 核心原则 -``` -1. 开始新功能 - git checkout main - git pull - git checkout -b feature/xxx - -2. 开发过程中(随时提交) - git add . - git commit -m "wip(xxx): 进度描述" - -3. 功能完成,测试通过 - git checkout main - git merge feature/xxx - git branch -d feature/xxx # 删除已合并分支 - -4. 到达里程碑 - git checkout main - git tag -a v0.1.0 -m "第一个可玩原型" - git checkout -b release/v0.1 - git push origin release/v0.1 -``` - -### 1.5 核心原则 - -- **`main` 永远可玩**:坏了就留在 feature 分支继续改,不要合并进 main -- **功能分支粒度**:一个分支做一件事,不要在一个分支里同时做多个系统 -- **原型不怕乱**:`prototype/` 分支随便写,验证完再决定合并还是丢弃 -- **定期清理**:已合并的 feature/fix 分支及时删除,避免分支堆积 +- **`main` 永远可玩**:通过 develop → main PR 合并,不直接推送 +- **develop 是工作台**:所有 agent 在 develop 上并行工作 +- **不同文件不冲突**:各 agent 修改不同文件,可真正并行 +- **同一文件需协调**:两个 agent 修改同一文件时需顺序执行 --- diff --git a/.claude/scripts/git-workflow.sh b/.claude/scripts/git-workflow.sh new file mode 100644 index 0000000..08651d8 --- /dev/null +++ b/.claude/scripts/git-workflow.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════════════ +# git-workflow.sh — ChildHood 项目 Git 工作流自动化 +# +# 基于 develop 长期开发分支模型。 +# 所有 agent 在 develop 上工作,迭代结束时通过 PR 合并回 main。 +# +# 用法: bash .claude/scripts/git-workflow.sh [args...] +# +# 命令: +# ready 确保在 develop 上且最新 +# commit "" 格式化提交 +# push 推送 develop 到远程 +# submit 一键 commit + push(从 Issue 拉取信息) +# release [milestone-name] 创建 develop→main 的 PR +# post-release 合并后同步 main 和 develop +# catchup 将 develop 合并最新 main 的内容 +# status 显示当前分支和状态 +# ═══════════════════════════════════════════════════════════════ + +set -euo pipefail + +# ─── 配置 ─── +DEVELOP_BRANCH="develop" +MAIN_BRANCH="main" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +GITEA_PM_CLI="node $PROJECT_ROOT/.claude/skills/gitea-pm/gitea.js" +GITEA_COLLAB_CLI="node $PROJECT_ROOT/.claude/skills/gitea-collab/gitea.js" + +# ─── 颜色 ─── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +info() { echo -e "${BLUE}ℹ️ $*${NC}"; } +ok() { echo -e "${GREEN}✅ $*${NC}"; } +warn() { echo -e "${YELLOW}⚠️ $*${NC}"; } +fail() { echo -e "${RED}❌ $*${NC}"; exit 1; } + +# ─── 当前分支 ─── +current_branch() { git rev-parse --abbrev-ref HEAD; } + +# ─── 检查是否有未提交的修改 ─── +has_changes() { [ -n "$(git status --porcelain)" ]; } + +# ─── 检测 UE5 二进制文件 ─── +has_ue5_binaries() { + git diff --cached --name-only 2>/dev/null | grep -qE '\.(uasset|umap)$' +} + +# ═══════════════════════════════════════════════════════════════ +# 命令实现 +# ═══════════════════════════════════════════════════════════════ + +cmd_ready() { + info "准备工作环境..." + + local branch + branch=$(current_branch) + + # 如果不在 develop 上,切换过去 + if [ "$branch" != "$DEVELOP_BRANCH" ]; then + if has_changes; then + warn "有未提交的修改,暂存中..." + git stash push --include-untracked -m "auto-stash-$(date +%s)" + fi + git checkout "$DEVELOP_BRANCH" 2>/dev/null || { + # 本地没有 develop 分支,从 main 创建 + info "本地没有 $DEVELOP_BRANCH 分支,从 $MAIN_BRANCH 创建..." + git checkout "$MAIN_BRANCH" + git pull origin "$MAIN_BRANCH" + git checkout -b "$DEVELOP_BRANCH" + } + fi + + # 拉取最新 develop + if git remote get-url origin &>/dev/null; then + info "拉取远程 $DEVELOP_BRANCH..." + git pull origin "$DEVELOP_BRANCH" 2>/dev/null || { + # 远程没有 develop,首次推送 + warn "远程没有 $DEVELOP_BRANCH 分支,将首次推送" + } + fi + + local commit_hash + commit_hash=$(git rev-parse --short HEAD) + ok "已在 $DEVELOP_BRANCH 分支上 (${commit_hash})" +} + +cmd_commit() { + local type="${1:-}" + local scope="${2:-}" + local message="${3:-}" + + if [ -z "$type" ] || [ -z "$scope" ] || [ -z "$message" ]; then + fail "用法: git-workflow.sh commit \"\"\n 示例: git-workflow.sh commit feat player \"实现角色跳跃\"" + fi + + # 校验分支 + if [ "$(current_branch)" != "$DEVELOP_BRANCH" ]; then + warn "当前不在 $DEVELOP_BRANCH 分支上 (当前: $(current_branch))" + read -rp "继续提交? [y/N] " confirm + [[ "$confirm" != [yY] ]] && { info "已取消"; return; } + fi + + # 暂存所有修改 + if ! has_changes; then + warn "没有检测到修改" + return + fi + + git add -A + + # UE5 二进制文件检测 + if has_ue5_binaries; then + warn "检测到 .uasset/.umap 文件,请确保 commit message 清楚描述了变更内容" + fi + + # 格式化 commit message + local commit_msg="${type}(${scope}): ${message}" + + # subject ≤ 50 字符检查(仅消息部分) + local msg_len=${#commit_msg} + if [ "$msg_len" -gt 72 ]; then + warn "提交消息较长 ($msg_len 字符),建议精简" + fi + + git commit -m "$commit_msg" + ok "已提交: $commit_msg" +} + +cmd_push() { + local branch + branch=$(current_branch) + + info "推送 $branch 到远程..." + + # 设置上游分支(首次) + if ! git rev-parse --abbrev-ref "@{upstream}" &>/dev/null; then + git push -u origin "$branch" + else + git push origin "$branch" + fi + + ok "已推送 $branch 到远程" +} + +cmd_submit() { + local issue_number="${1:-}" + if [ -z "$issue_number" ]; then + fail "用法: git-workflow.sh submit " + fi + + info "提交工作 (Issue #$issue_number)..." + + # 从 Issue 拉取标题作为 commit 参考 + local issue_title="" + issue_title=$($GITEA_COLLAB_CLI --role dev issue get "$issue_number" --json 2>/dev/null | grep -o '"title":"[^"]*"' | head -1 | sed 's/"title":"//;s/"//') || true + + # 提交 + if has_changes; then + if [ -n "$issue_title" ]; then + info "Issue 标题: $issue_title" + fi + warn "有未提交的修改,请先运行: git-workflow.sh commit \"\"" + return + fi + + # 推送 + cmd_push + + ok "工作已提交到 $DEVELOP_BRANCH (关联 Issue #$issue_number)" +} + +cmd_release() { + local milestone_name="${1:-}" + + info "准备发布 PR ($DEVELOP_BRANCH → $MAIN_BRANCH)..." + + # 确保在 develop 上 + if [ "$(current_branch)" != "$DEVELOP_BRANCH" ]; then + fail "请先切换到 $DEVELOP_BRANCH 分支" + fi + + # 确保本地最新 + git pull origin "$DEVELOP_BRANCH" + + # 获取里程碑名称 + if [ -z "$milestone_name" ]; then + # 尝试从当前里程碑获取 + milestone_name=$($GITEA_PM_CLI --role pm milestone list --state open --json 2>/dev/null | grep -o '"title":"[^"]*"' | head -1 | sed 's/"title":"//;s/"//') || "迭代发布" + if [ -z "$milestone_name" ]; then + milestone_name="迭代发布" + fi + fi + + local pr_title="Merge develop → main: ${milestone_name}" + + # 构造 PR body(汇总已关闭的 Issue) + local pr_body="## 发布合并\n\n将 \`${DEVELOP_BRANCH}\` 分支的成果合并到 \`${MAIN_BRANCH}\`。\n\n### 里程碑\n${milestone_name}" + + # 创建 PR + info "创建 PR: $pr_title" + local pr_result + pr_result=$($GITEA_PM_CLI --role pm pr create \ + --base "$MAIN_BRANCH" \ + --head "$DEVELOP_BRANCH" \ + --title "$pr_title" \ + --body "$(echo -e "$pr_body")" \ + --json 2>&1) || { + fail "创建 PR 失败: $pr_result" + } + + local pr_number + pr_number=$(echo "$pr_result" | grep -o '"number":[0-9]*' | head -1 | sed 's/"number"://') || true + + if [ -n "$pr_number" ]; then + ok "PR #$pr_number 已创建: $pr_title" + info "请在 Gitea 审查并合并: http://micecola.top:8418/starmen1265/ChildHood/pulls/$pr_number" + else + ok "PR 已创建,请查看 Gitea" + fi +} + +cmd_post_release() { + info "合并后同步..." + + # 同步 main + info "同步 $MAIN_BRANCH..." + git checkout "$MAIN_BRANCH" + git pull origin "$MAIN_BRANCH" + ok "$MAIN_BRANCH 已更新" + + # 切回 develop 并合并 main(保持 develop 包含 merge commit) + info "同步 $DEVELOP_BRANCH..." + git checkout "$DEVELOP_BRANCH" + git merge "$MAIN_BRANCH" --no-edit + git push origin "$DEVELOP_BRANCH" + ok "$DEVELOP_BRANCH 已同步(包含 $MAIN_BRANCH 的 merge commit)" + + local commit_hash + commit_hash=$(git rev-parse --short HEAD) + ok "完成,当前在 $DEVELOP_BRANCH (${commit_hash})" +} + +cmd_catchup() { + info "将 develop 跟上最新的 $MAIN_BRANCH..." + + if [ "$(current_branch)" != "$DEVELOP_BRANCH" ]; then + fail "请先切换到 $DEVELOP_BRANCH 分支" + fi + + git fetch origin + git merge "origin/$MAIN_BRANCH" --no-edit + ok "$DEVELOP_BRANCH 已合并最新 $MAIN_BRANCH" +} + +cmd_status() { + local branch + branch=$(current_branch) + local commit_hash + commit_hash=$(git rev-parse --short HEAD) + local changed + changed=$(git status --porcelain | wc -l | tr -d ' ') + + echo -e "${BLUE}═══ Git 工作流状态 ═══${NC}" + echo " 当前分支: ${branch} (${commit_hash})" + + if [ "$branch" = "$DEVELOP_BRANCH" ]; then + echo -e " ${GREEN}✓ 在 develop 开发分支上${NC}" + elif [ "$branch" = "$MAIN_BRANCH" ]; then + echo -e " ${YELLOW}⚠ 在 main 分支上,请切换到 develop 开始工作${NC}" + else + echo -e " ${YELLOW}⚠ 在非标准分支上: $branch${NC}" + fi + + echo " 未提交修改: ${changed} 个文件" + + if [ "$changed" -gt 0 ]; then + echo "" + git status --short + fi + + # 远程同步状态 + if git rev-parse "@{upstream}" &>/dev/null; then + local ahead behind + ahead=$(git rev-list --count "@{upstream}..HEAD" 2>/dev/null || echo "0") + behind=$(git rev-list --count "HEAD..@{upstream}" 2>/dev/null || echo "0") + echo " 远程状态: ahead ${ahead}, behind ${behind}" + fi +} + +# ═══════════════════════════════════════════════════════════════ +# 入口 +# ═══════════════════════════════════════════════════════════════ + +case "${1:-}" in + ready) cmd_ready ;; + commit) shift; cmd_commit "$@" ;; + push) cmd_push ;; + submit) shift; cmd_submit "$@" ;; + release) shift; cmd_release "$@" ;; + post-release) cmd_post_release ;; + catchup) cmd_catchup ;; + status) cmd_status ;; + *) + echo "git-workflow.sh — ChildHood 项目 Git 工作流自动化" + echo "" + echo "用法: bash .claude/scripts/git-workflow.sh [args...]" + echo "" + echo "命令:" + echo " ready 确保在 develop 上且最新" + echo " commit \"\" 格式化提交" + echo " push 推送 develop 到远程" + echo " submit 推送并关联 Issue" + echo " release [milestone-name] 创建 develop→main PR" + echo " post-release 合并后同步 main 和 develop" + echo " catchup 将 develop 合并最新 main" + echo " status 显示当前分支和状态" + echo "" + echo "工作流模型: develop 长期开发分支 → 迭代完成时 PR 合并回 main" + ;; +esac diff --git a/.claude/skills/gitea-collab/SKILL.md b/.claude/skills/gitea-collab/SKILL.md index 8c533e8..01a5646 100644 --- a/.claude/skills/gitea-collab/SKILL.md +++ b/.claude/skills/gitea-collab/SKILL.md @@ -148,7 +148,43 @@ node .claude/skills/gitea-collab/gitea.js --role reviewer issue labels --- -## 7. 汇报模板 +## 7. PR 查询 + +非 PM 角色可以查看 PR 状态(创建和合并由 PM 负责): + +```bash +# 列出 PR +node .claude/skills/gitea-collab/gitea.js --role <角色名> pr list + +# 查看 PR 详情 +node .claude/skills/gitea-collab/gitea.js --role <角色名> pr get +``` + +--- + +## 8. Git 工作流 + +所有 agent 在 `develop` 长期开发分支上工作: + +```bash +# 开始工作时:确保在 develop 上 +bash .claude/scripts/git-workflow.sh ready + +# 提交代码/文档 +bash .claude/scripts/git-workflow.sh commit "中文描述" + +# 推送到远程 +bash .claude/scripts/git-workflow.sh push + +# 查看当前状态 +bash .claude/scripts/git-workflow.sh status +``` + +详见 `.claude/refs/GIT-GUIDE-LITE.md` 的"自动化工作流"章节。 + +--- + +## 9. 汇报模板 ### 策划汇报 diff --git a/.claude/skills/gitea-collab/gitea-api.js b/.claude/skills/gitea-collab/gitea-api.js index c5b2e8c..44a392c 100644 --- a/.claude/skills/gitea-collab/gitea-api.js +++ b/.claude/skills/gitea-collab/gitea-api.js @@ -222,6 +222,27 @@ class GiteaAPI { }); } + // ====== Pull Requests ====== + + listPullRequests(params = {}) { + return this.request('GET', '/pulls', null, { state: 'open', limit: 50, ...params }); + } + + getPullRequest(number) { + return this.request('GET', `/pulls/${number}`); + } + + createPullRequest(data) { + return this.request('POST', '/pulls', data); + } + + mergePullRequest(number, data = {}) { + return this.request('POST', `/pulls/${number}/merge`, { + Do: data.style || 'merge', + ...data, + }); + } + // ====== 辅助方法 ====== /** 通过名称查找标签,返回 ID(不存在则返回 null) */ diff --git a/.claude/skills/gitea-collab/gitea.js b/.claude/skills/gitea-collab/gitea.js index fd4e1f0..a45e82b 100644 --- a/.claude/skills/gitea-collab/gitea.js +++ b/.claude/skills/gitea-collab/gitea.js @@ -520,6 +520,84 @@ cmd.pipeline = { }, }; +// ──────── pr ──────── + +cmd.pr = { + async list(api, opts) { + const params = {}; + if (opts.options.state) params.state = opts.options.state; + if (opts.options.base) params.base = opts.options.base; + if (opts.options.head) params.head = opts.options.head; + + const prs = await api.listPullRequests(params); + out(prs); + if (!opts.options.json) { + outText(`\n🔀 Pull Requests (${prs.length || 0})\n`); + if (!prs || !prs.length) { outText(' (无)'); return; } + table( + ['#', '标题', '状态', 'Base', 'Head', '合并'], + prs.map(p => [ + p.number, + truncate(p.title, 35), + p.state, + p.base?.ref || '—', + p.head?.ref || '—', + p.merged ? '✅' : '—', + ]) + ); + } + }, + + async get(api, opts) { + const num = opts.rest[0]; + if (!num) { fail('用法: pr get '); return; } + const pr = await api.getPullRequest(num); + out(pr); + if (!opts.options.json) { + outText(`\n🔀 PR #${pr.number}: ${pr.title}`); + outText(` 状态: ${pr.state}`); + outText(` ${pr.base?.ref} ← ${pr.head?.ref}`); + outText(` 合并: ${pr.merged ? '✅ 已合并' : '❌ 未合并'}`); + if (pr.body) { + outText(`\n--- Body ---\n${pr.body.slice(0, 500)}${pr.body.length > 500 ? '…' : ''}`); + } + } + }, + + async create(api, opts) { + const base = opts.options.base || 'main'; + const head = opts.options.head; + const title = opts.options.title; + const body = opts.options.body || ''; + + if (!head || !title) { + fail('用法: pr create --head <分支> --title "标题" [--base main] [--body "描述"]'); + return; + } + + const pr = await api.createPullRequest({ base, head, title, body }); + out(pr); + if (!opts.options.json) { + ok(`PR #${pr.number} 已创建: ${pr.title}`); + outText(` ${pr.html_url}`); + } + }, + + async merge(api, opts) { + const num = opts.rest[0]; + if (!num) { fail('用法: pr merge [--style merge|squash|rebase]'); return; } + const style = opts.options.style || 'merge'; + + const pr = await api.getPullRequest(num); + if (pr.merged) { fail(`PR #${num} 已合并,无需重复操作`); return; } + if (pr.mergeable === false) { fail(`PR #${num} 存在冲突,无法自动合并。请先解决冲突。`); return; } + + const result = await api.mergePullRequest(num, { style }); + out(result); + if (!opts.options.json) ok(`PR #${num} 已合并 (${style})`); + }, +}; + // ──────── test ──────── cmd.test = { @@ -682,6 +760,11 @@ Gitea CLI — 项目管理工具 pipeline status 查看当前流水线进度 pipeline update <#> 更新 Pipeline 字段 --field <阶段|当前迭代> --value <值> + pr list 列出 Pull Requests [--state open|closed|all] [--base <分支>] [--head <分支>] + pr get <#> 查看 PR 详情 + pr create 创建 PR --head <分支> --title "标题" [--base main] [--body "描述"] + pr merge <#> 合并 PR [--style merge|squash|rebase] + test 验证所有 API 接口 角色自动映射: diff --git a/.claude/skills/gitea-pm/SKILL.md b/.claude/skills/gitea-pm/SKILL.md index 257958e..9863c71 100644 --- a/.claude/skills/gitea-pm/SKILL.md +++ b/.claude/skills/gitea-pm/SKILL.md @@ -218,7 +218,36 @@ Pipeline Tracker 是一个 Meta Issue,仅记录**无法从 Issue/Milestone 推 --- -## 8. 标签体系 +## 8. PR 操作(发布合并) + +迭代完成时,PM 负责创建 develop → main 的 PR: + +```bash +# 列出当前 PR +node .claude/skills/gitea-pm/gitea.js --role pm pr list + +# 创建发布 PR +bash .claude/scripts/git-workflow.sh release "迭代名称" + +# 或手动创建 +node .claude/skills/gitea-pm/gitea.js --role pm pr create \ + --base main --head develop \ + --title "Merge develop → main: 迭代名称" \ + --body "发布描述" + +# 查看 PR 详情 +node .claude/skills/gitea-pm/gitea.js --role pm pr get + +# 合并 PR(用户审查确认后) +node .claude/skills/gitea-pm/gitea.js --role pm pr merge --style merge + +# 合并后同步 +bash .claude/scripts/git-workflow.sh post-release +``` + +--- + +## 9. 标签体系 从 `gitea-config.json` 的 `labels` 字段定义。`label init` 命令从此配置创建标签。 diff --git a/.claude/skills/gitea-pm/gitea-api.js b/.claude/skills/gitea-pm/gitea-api.js index c5b2e8c..98c4524 100644 --- a/.claude/skills/gitea-pm/gitea-api.js +++ b/.claude/skills/gitea-pm/gitea-api.js @@ -222,6 +222,27 @@ class GiteaAPI { }); } + // ====== Pull Requests ====== + + listPullRequests(params = {}) { + return this.request('GET', '/pulls', null, { state: 'open', limit: 50, ...params }); + } + + getPullRequest(number) { + return this.request('GET', `/pulls/${number}`); + } + + createPullRequest(data) { + return this.request('POST', '/pulls', data); + } + + mergePullRequest(number, data = {}) { + return this.request('POST', `/pulls/${number}/merge`, { + Do: data.style || 'merge', // merge | squash | rebase + ...data, + }); + } + // ====== 辅助方法 ====== /** 通过名称查找标签,返回 ID(不存在则返回 null) */ diff --git a/.claude/skills/gitea-pm/gitea.js b/.claude/skills/gitea-pm/gitea.js index fd4e1f0..42caedb 100644 --- a/.claude/skills/gitea-pm/gitea.js +++ b/.claude/skills/gitea-pm/gitea.js @@ -520,6 +520,87 @@ cmd.pipeline = { }, }; +// ──────── pr ──────── + +cmd.pr = { + async list(api, opts) { + const params = {}; + if (opts.options.state) params.state = opts.options.state; + if (opts.options.base) params.base = opts.options.base; + if (opts.options.head) params.head = opts.options.head; + + const prs = await api.listPullRequests(params); + out(prs); + if (!opts.options.json) { + outText(`\n🔀 Pull Requests (${prs.length || 0})\n`); + if (!prs || !prs.length) { outText(' (无)'); return; } + table( + ['#', '标题', '状态', 'Base', 'Head', '合并'], + prs.map(p => [ + p.number, + truncate(p.title, 35), + p.state, + p.base?.ref || '—', + p.head?.ref || '—', + p.merged ? '✅' : '—', + ]) + ); + } + }, + + async get(api, opts) { + const num = opts.rest[0]; + if (!num) { fail('用法: pr get '); return; } + const pr = await api.getPullRequest(num); + out(pr); + if (!opts.options.json) { + outText(`\n🔀 PR #${pr.number}: ${pr.title}`); + outText(` 状态: ${pr.state}`); + outText(` ${pr.base?.ref} ← ${pr.head?.ref}`); + outText(` 合并: ${pr.merged ? '✅ 已合并' : '❌ 未合并'}`); + outText(` 创建: ${pr.created_at?.slice(0, 10)}`); + outText(` 更新: ${pr.updated_at?.slice(0, 10)}`); + if (pr.body) { + outText(`\n--- Body ---\n${pr.body.slice(0, 500)}${pr.body.length > 500 ? '…' : ''}`); + } + } + }, + + async create(api, opts) { + const base = opts.options.base || 'main'; + const head = opts.options.head; + const title = opts.options.title; + const body = opts.options.body || ''; + + if (!head || !title) { + fail('用法: pr create --head <分支> --title "标题" [--base main] [--body "描述"]'); + return; + } + + const pr = await api.createPullRequest({ base, head, title, body }); + out(pr); + if (!opts.options.json) { + ok(`PR #${pr.number} 已创建: ${pr.title}`); + outText(` ${pr.html_url}`); + } + }, + + async merge(api, opts) { + const num = opts.rest[0]; + if (!num) { fail('用法: pr merge [--style merge|squash|rebase]'); return; } + const style = opts.options.style || 'merge'; + + // 先检查 PR 状态 + const pr = await api.getPullRequest(num); + if (pr.merged) { fail(`PR #${num} 已合并,无需重复操作`); return; } + if (pr.mergeable === false) { fail(`PR #${num} 存在冲突,无法自动合并。请先解决冲突。`); return; } + + const result = await api.mergePullRequest(num, { style }); + out(result); + if (!opts.options.json) ok(`PR #${num} 已合并 (${style})`); + }, +}; + // ──────── test ──────── cmd.test = { @@ -682,6 +763,11 @@ Gitea CLI — 项目管理工具 pipeline status 查看当前流水线进度 pipeline update <#> 更新 Pipeline 字段 --field <阶段|当前迭代> --value <值> + pr list 列出 Pull Requests [--state open|closed|all] [--base <分支>] [--head <分支>] + pr get <#> 查看 PR 详情 + pr create 创建 PR --head <分支> --title "标题" [--base main] [--body "描述"] + pr merge <#> 合并 PR [--style merge|squash|rebase] + test 验证所有 API 接口 角色自动映射: From 2342b63fc2db7cbad73dec7433b1531bc11e7cec Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Thu, 11 Jun 2026 19:04:14 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(technical):=20=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=8A=80=E6=9C=AF=E6=96=B9=E6=A1=88=20v1.0?= =?UTF-8?q?=20=E9=87=8D=E5=86=99=EF=BC=9AEffect/Action=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=A9=B1=E5=8A=A8=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/agent-memory/game-dev/MEMORY.md | 1 + .../interaction-effect-architecture.md | 31 + docs/technical/交互系统-技术方案.md | 1597 ++++++++--------- docs/technical/交互系统-拾取时序图.md | 224 +++ 4 files changed, 985 insertions(+), 868 deletions(-) create mode 100644 .claude/agent-memory/game-dev/interaction-effect-architecture.md create mode 100644 docs/technical/交互系统-拾取时序图.md diff --git a/.claude/agent-memory/game-dev/MEMORY.md b/.claude/agent-memory/game-dev/MEMORY.md index a75b164..8a25ee9 100644 --- a/.claude/agent-memory/game-dev/MEMORY.md +++ b/.claude/agent-memory/game-dev/MEMORY.md @@ -2,3 +2,4 @@ - [UE5.7 Python API](ue57-python-api.md) — 实测 API 差异(set_actor_rotation、new_level、CWD、import 路径、Rotator 关键字参数) - [V01 Bugfix](level-builder-bugfix-v01.md) — 8个白模关卡问题诊断修复(搭建指南6+解析器双重解析+构建器静默fallback) - [BSP Pivot Types](bsp-pivot-types.md) — 38种 BSP 资产实测锚点+自动补偿机制(角落/底边中心/底面中心) +- [交互系统架构](interaction-effect-architecture.md) — v1.0 Effect/Action 数据驱动架构(Manager在GI、AInfo Tick、门派生、Prompt独立) diff --git a/.claude/agent-memory/game-dev/interaction-effect-architecture.md b/.claude/agent-memory/game-dev/interaction-effect-architecture.md new file mode 100644 index 0000000..658ac86 --- /dev/null +++ b/.claude/agent-memory/game-dev/interaction-effect-architecture.md @@ -0,0 +1,31 @@ +--- +name: interaction-effect-architecture +description: 交互系统 v1.0 Effect/Action 数据驱动架构决策记录 +metadata: + type: project +--- + +## 交互系统架构决策(v1.0 重构,2026-06-11) + +### 核心架构:Effect/Action 数据驱动 + +交互物不关心"交互后发生什么",只配置 Effect(由 Action 组成)交给 EffectManager 执行。 + +**Why:** v0.1 用 switch(Type) 硬编码,新增交互类型需改代码。v1.0 配数据即可。 + +### Manager 归属 +- FInteractionManager / FEffectManager 是普通 C++ 类,不是 UObject +- 由 ASystemDriver (AInfo) 持有,随 World 生灭 +- 不依赖 UE Subsystem 生命周期,显式控制创建顺序和 Tick 顺序 +- 所有 UObject 引用用 TWeakObjectPtr,回调用 TFunction +- GameInstance 不持有任何交互系统对象,只有 UPersistenceSubsystem 跨 World + +### 分层规则 +- 表现层只读 Manager 缓存(getter),不碰 Component +- Manager 在选择变化时从 Component 读一次并缓存 +- 逻辑事件仅用于 "A 影响 B"(如 Consume → Persistence) + +### 协调方式 +- Character 输入回调通过 ASystemDriver::Get(World) 访问两个 Manager +- Driver::BeginPlay 中 TFunction 连线 EffectManager.OnEffectComplete → Manager.OnInteractionFinished +- PromptComponent 通过 Driver 获取 Manager,只读缓存 diff --git a/docs/technical/交互系统-技术方案.md b/docs/technical/交互系统-技术方案.md index 844d97f..7f1971f 100644 --- a/docs/technical/交互系统-技术方案.md +++ b/docs/technical/交互系统-技术方案.md @@ -1,67 +1,142 @@ -# 交互系统 技术设计文档(v0.1) +# 交互系统 技术设计文档(v1.0 重构版) > 对应执行案: `docs/design/交互系统-执行案.md` (v0.1.2) > 关联系统: 角色控制系统、物品系统、收集系统、GameInstance 持久化层 -> 日期: 2026-06-07(2026-06-11 更新:加入多场景持久化架构) +> 日期: 2026-06-11(v1.0 全面重构:Effect/Action 数据驱动架构) + +--- + +## 迭代记录 + +| 版本 | 日期 | 变更内容 | +|------|------|----------| +| v0.1 | 2026-06-07 | 初版技术方案,基于类型分发的状态机架构 | +| v1.0 | 2026-06-11 | 全面重构:Effect/Action 数据驱动架构;普通 C++ Manager 挂 AInfo;门派生类;持久化 Subsystem;逻辑/表现解耦 | --- ## 概述 -交互系统让玩家通过接近并按 E 键的方式与环境中的物体和 NPC 产生互动。v0.1 原型实现四种交互类型(观察/拾取/操作/对话),覆盖完整的层级状态机和类型分发逻辑。 +交互系统让玩家通过接近并按 E 键的方式与环境中的物体和 NPC 产生互动。v1.0 采用 **Effect/Action 数据驱动架构**替代 v0.1 的类型分发状态机: + +- **Effect**:由多个 Action 按时间线编排而成的交互效果配方 +- **Action**:原子操作单元(播放动画、推镜、消耗、物品进背包等) +- **EffectManager**:执行 Effect 的 Timeline 调度器 +- **交互物不关心"交互后发生什么"**,只负责触发配置好的 Effect **实现复杂度**:中-高 -**预估工期**:5-7 天 -**新增/修改代码量**:约 600-800 行 C++ + 3 个 UMG 蓝图 +**预估工期**:6-8 天 +**新增/修改代码量**:约 800-1000 行 C++ + 3 个 UMG 蓝图 -### 跨场景持久化架构 +### 架构核心变更(v0.1 → v1.0) -> 本项目后续将支持多场景切换(如城郊 → 乡下)。UE5 的 `OpenLevel()` 会销毁整个旧 World,包括 Character 及其所有 UActorComponent。因此需要将**持久数据**与**运行时行为**分离: +| 维度 | v0.1 | v1.0 | +|------|------|------| +| 交互类型 | `EInteractType` 枚举 + Manager 内 `switch` | ❌ 不需要,Effect 定义一切 | +| 状态机 | 5 阶段(Free/Enter/AnimLock/Active/Exit) | 2 阶段(Free/Executing) | +| 新增交互类型 | 改代码加 case | 配数据写 Effect | +| Manager 位置 | Character 上的 UActorComponent | ASystemDriver (AInfo) 持有的普通 C++ 类 | +| Tick 驱动 | TickComponent | ASystemDriver Tick + PromptComponent 自有 Tick | +| 持久化 | GameInstance 直接持有域数据 | UPersistenceSubsystem | +| 门逻辑 | 基类硬编码 | ADoorActor + UDoorInteractableComponent 派生 | +| 表现耦合 | Manager 持有 Camera/Widget | Prompt 独立,Effect 内 Action 各自处理 | +| 跨场景 | 直接调 GameInstance | 通过 UPersistenceSubsystem | -- **持久数据层**:`UChildHoodGameInstance`(跨关卡存活),存储已收集物品 ID、门状态等 -- **行为层**:`UInteractionManager` / `UInteractableComponent`(随 Character/场景销毁),仅负责运行时交互逻辑 -- **恢复机制**:`AInteractableActor::BeginPlay()` 时查询 GameInstance,若物品已收集则隐藏并禁用交互 +--- -v0.1 为单场景原型,此架构以接口预留形式存在,不影响当前功能。详见 [第 12 节:跨场景持久化设计](#12-跨场景持久化设计)。 +## 架构分层 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ GameInstance 层(跨 World 持久,只有持久数据) │ +│ │ +│ UPersistenceSubsystem │ +│ └─ CollectedItemIDs / DoorStates / ResetAll │ +│ │ +├──────────────────────────────────────────────────────────────────┤ +│ World 层(per-world,随 World 生灭) │ +│ │ +│ ASystemDriver (AInfo, 全局唯一, 持有所有 World 级 Manager) │ +│ ├─ FInteractionManager(普通 C++,纯事件驱动,无 Tick) │ +│ │ ├─ 检测列表 / 选中管理 / 顶层状态 Free/Executing │ +│ │ ├─ 缓存 Presentation 数据 (PromptText, WorldPos) │ +│ │ └─ 所有 UObject 引用用 TWeakObjectPtr,不持有强引用 │ +│ │ │ +│ ├─ FEffectManager(普通 C++,需 Tick,由 Driver 驱动) │ +│ │ ├─ Effect Timeline 调度 / Action 执行分发 / Turn 插值 │ +│ │ └─ 所有 UObject 引用用 TWeakObjectPtr,不持有强引用 │ +│ │ │ +│ ├─ BeginPlay: 显式创建和初始化,控制顺序 │ +│ └─ Tick: 显式调用 EffectManager->Update(Delta),控制顺序 │ +│ └→ (未来其他 Manager 也在此 Tick,显式排序) │ +│ │ +│ 协调:Driver::BeginPlay 中 std::function 连线 │ +│ 协调:Character 输入回调中通过 Driver 访问两个 Manager │ +│ │ +│ AInteractableActor + UInteractableComponent (通用基类) │ +│ └─ ADoorActor + UDoorInteractableComponent (门派生) │ +│ └─ FInteractableData + FInteractEffect (配置 + Effect 定义) │ +│ └─ 碰撞注册 → Driver->InteractionManager │ +│ └─ 对 Presentation 完全不可见 │ +│ │ +├──────────────────────────────────────────────────────────────────┤ +│ Presentation 层(Character 上) │ +│ │ +│ UInteractionPromptComponent │ +│ └─ 自有 TickComponent,只读 Manager 缓存 → Prompt 位置/文本 │ +│ └─ 不直接访问任何 Component,通过 Driver 获取 Manager │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 数据流规则 + +``` +┌──────────────┐ 直接读 ┌──────────────┐ 选择变化时 ┌──────────────┐ +│ Presentation │ ────────→ │ Logic │ ←─────────── │ World │ +│ (Prompt) │ │ (Manager │ 读一次并缓存 │ (Components │ +│ │ │ 缓存数据) │ │ 不可见) │ +└──────────────┘ └──────┬───────┘ └──────────────┘ + │ + │ 逻辑事件 (A 影响 B) + ↓ + ┌──────────────┐ + │ Persistence │ + │ Subsystem │ + └──────────────┘ +``` + +- **表现层只读 Manager 缓存** — 不碰 Component,不碰任何 World 层对象 +- **Manager 在选择变化时从 Component 读一次并缓存** — Component 对 Presentation 不可见 +- **逻辑事件仅在 A 影响 B 时** — 如 Manager → Persistence 写入持久层 +- **两个表现实体之间不直接访问** — 各自从 Manager 读 --- ## 1. 需求功能点罗列 -将执行文档中所有行为规则逐一列出,按实现方式分类。 - ### UE 内置功能 -以下功能点直接使用 UE5 引擎内置能力,无需或仅需极少量自定义代码: - -| 功能点 | 使用的 UE 内置能力 | 对应行为规则 | 备注 | -|--------|-------------------|-------------|------| -| 交互触发输入(E/ESC/Tab/滚轮) | Enhanced Input (IA_Interact / IA_Cancel / IA_SwitchInteract) | 通用触发/关闭/切换 | 通过 IMC 映射 | -| 摄像机推镜与回位 | SetViewTargetWithBlend (VTBlend_EaseOut/EaseIn) | E1-E2/E5/S1-S2/K1-K2/K5 | 推镜用 EaseOut,回位用 EaseIn | -| 移动输入屏蔽 | CMC + bIsInteracting 标志 | 通用触发规则 | DoMove() 中检查标志 | -| 碰撞检测(进入/离开范围) | SphereComponent Overlap 事件 | 触发条件 | 每个交互物独立半径 | -| UMG Widget 生命周期 | UUserWidget AddToViewport / RemoveFromParent | 提示 UI / 文本 UI | 淡入淡出用 UMG 内建动画 | -| 动画播放 | AnimMontage + AnimInstance::Montage_Play | P2/D2/S3 | 有蒙太奇时使用,无则计时器替代 | +| 功能点 | 使用的 UE 内置能力 | 对应行为规则 | +|--------|-------------------|-------------| +| 交互触发输入 | Enhanced Input (IA_Interact/IA_Cancel/IA_SwitchInteract) | 通用触发/关闭/切换 | +| 摄像机推镜与回位 | SetViewTargetWithBlend | CameraPush/CameraReturn Action | +| 移动输入屏蔽 | CMC + bIsInteracting 标志 | 通用触发规则 | +| 碰撞检测 | SphereComponent Overlap 事件 | 触发条件 | +| UMG Widget 生命周期 | UUserWidget AddToViewport / RemoveFromParent | 提示 UI | +| 动画播放 | AnimMontage + AnimInstance::Montage_Play | PlayMontage Action | ### 自定义实现 -以下功能点需要编写自定义 C++ / 蓝图代码: - -| 功能点 | 实现方式概述 | 对应行为规则 | 预估复杂度 | -|--------|-------------|-------------|-----------| -| 层级状态机(Free/Interacting + 子阶段) | UActorComponent 内状态枚举 + TickComponent 驱动转换 | 状态定义/转换规则 | 高 | -| 可交互物检测与选择 | 碰撞注册列表 + 水平距离排序 + 选中索引管理 | 提示 UI 显示/切换规则 | 中 | -| 交互类型分发(4 类 + 操作子类型) | EInteractType + EOperateSubType 枚举 + switch | 各类型触发后行为 | 中 | -| 角色自动转向 | TickComponent 中 FMath::RInterpTo 插值旋转 | 各类型转向阶段 | 低 | -| 推镜目标位置计算 | 交互物位置 + 方向偏移 → 定位临时 CameraActor | E1-E2/S1-S2/K1-K2 | 低 | -| 提示 UI 屏幕空间投影 | ProjectWorldLocationToScreen + Widget 锚点偏移 | 提示 UI 位置/显示 | 低 | -| 文本底框显示控制 | UMG Widget 显隐 + 淡入淡出动画 | 通用文本显示 UI | 低 | -| 门开关状态管理 | 布尔状态 + Mesh 旋转 + 提示文字切换 | D3/D6 | 低 | -| oneShot 消耗管理 | 布尔标记 + 检测区域注销 | 触发条件/边界情况 | 低 | -| 交互物配置数据 | FInteractableData USTRUCT + 编辑器属性暴露 | 全部 | 低 | -| 交互系统→物品系统通知 | 多播委托 OnItemPickedUp / OnItemFound | P4/S5 | 低 | - -> **分类原则**:UE 内置能力覆盖 80% 以上需求且行为一致 → UE 内置;需要自定义状态管理、跨系统协调或特殊逻辑 → 自定义实现。 +| 功能点 | 实现方式概述 | 预估复杂度 | +|--------|-------------|-----------| +| 检测与选择 | 碰撞注册列表 + 水平距离排序 + 选中索引管理 | 中 | +| Effect Timeline 调度 | EffectManager 按 TriggerTime 触发 Action | 高 | +| Action 执行分发 | switch(ActionType) 分发到各 Action 处理逻辑 | 中 | +| Effect 数据定义 | FInteractEffect + FInteractAction USTRUCT | 低 | +| Prompt 屏幕投影 | ProjectWorldToScreen + Widget 位置更新 | 低 | +| 门开关状态管理 | UDoorInteractableComponent 派生类 + Mesh 旋转 | 低 | +| 持久化 | UPersistenceSubsystem (GameInstanceSubsystem) | 低 | --- @@ -69,322 +144,311 @@ v0.1 为单场景原型,此架构以接口预留形式存在,不影响当前 ```mermaid flowchart TD - subgraph 玩家侧 - Input[输入处理模块] - CharState[角色状态模块] + subgraph GameInstance层 + PS[UPersistenceSubsystem
跨场景持久数据
唯一跨World存活对象] end - subgraph 交互核心 - Detection[检测与选择模块] - StateMachine[交互状态机模块] - TypeHandler[类型处理器] - TurnCtrl[角色转向模块] - CameraCtrl[摄像机控制模块] + subgraph World层 + Driver[ASystemDriver
AInfo 全局唯一
持有所有World级Manager
显式控制创建/Tick顺序] + Mgr["FInteractionManager
(普通C++,纯事件驱动,无Tick)"] + EFM["FEffectManager
(普通C++,Driver提供Tick)"] end - subgraph 可交互物侧 - InteractableData[交互物数据模块] - Trigger[触发区域模块] + subgraph Interactable层 + IC[UInteractableComponent
配置 + 检测 + 运行时状态] + DC[UDoorInteractableComponent
门开关状态] + IA[AInteractableActor] + DA[ADoorActor] end - subgraph 表现层 - PromptUI[提示 UI 模块] - TextUI[文本底框 UI 模块] + subgraph Character协调层 + Char[AChildHoodCharacter
输入回调中通过Driver访问两个Manager] + end + + subgraph Presentation层 + Prompt[UInteractionPromptComponent
自有Tick,只读Manager缓存
不碰Component] end subgraph 下游系统 - ItemSys[物品系统] + Bag[BackpackSystem] end - Input -->|E/ESC/Tab/滚轮| StateMachine - Trigger -->|注册/注销| Detection - InteractableData -->|FInteractableData| Detection - Detection -->|排序列表 + 选中| StateMachine - StateMachine -->|锁定/解锁移动| CharState - StateMachine -->|转向目标| TurnCtrl - StateMachine -->|推镜/回位| CameraCtrl - StateMachine -->|类型分发| TypeHandler - StateMachine -->|显隐/更新内容| PromptUI - StateMachine -->|显示/淡出文本| TextUI - TypeHandler -.->|拾取/翻找获得| ItemSys + Driver -->|持有| Mgr + Driver -->|持有| EFM + Input[Enhanced Input] --> Char + IC -->|碰撞注册| Driver + Char -->|Driver->InteractionMgr| Mgr + Char -->|Driver->EffectMgr| EFM + Driver -->|Tick| EFM + EFM -->|std::function 回调| Mgr + EFM -->|Action 执行| IA + EFM -->|Action 执行| PS + EFM -.->|AddToBackpack Action| Bag + Prompt -->|只读缓存| Mgr + DA -->|继承| IA + DC -->|继承| IC ``` -### 模块职责说明 - -| 模块 | 职责 | 关键输入 | 关键输出 | -|------|------|---------|---------| -| 输入处理模块 | 接收 Enhanced Input 回调,转发给状态机 | IA_Interact / IA_Cancel / IA_SwitchInteract | BeginInteract / CancelInteract / SwitchTarget | -| 检测与选择模块 | 维护附近可交互物列表,按距离排序,管理选中索引 | SphereComponent 碰撞事件 | NearbyInteractables 列表 + SelectedIndex | -| 交互状态机模块 | 管理层级状态转换,Tick 驱动各阶段流转 | 输入 + 检测结果 + 计时器 | 驱动其他模块的指令 | -| 类型处理器 | 按 EInteractType 分发到具体行为逻辑 | 当前交互物类型 + 当前阶段 | 类型特定动作 | -| 角色转向模块 | Interacting/Enter 阶段插值旋转角色面朝交互物 | 目标旋转 + 已用时间 | 角色旋转更新 | -| 摄像机控制模块 | 推镜(SetViewTargetWithBlend→临时 CameraActor)/ 回位 | 目标位置 + 持续时间 | ViewTarget 切换 | -| 交互物数据模块 | 存储单个可交互物的全部配置数据和运行时状态 | 编辑器配置 / 运行时更新 | FInteractableData | -| 触发区域模块 | 检测玩家进入/离开交互范围 | SphereComponent 碰撞 | 注册/注销到检测列表 | -| 提示 UI 模块 | 屏幕空间悬浮提示(`[E] 查看旧冰箱`) | 选中状态变化 + 交互物位置 | 视觉提示 | -| 文本底框 UI 模块 | 半透明底框 + 一句话叙事/对话文本 | 状态机指令 | 文本展示 + 淡出 | -| 物品系统(下游) | 接收拾取/获得通知,处理背包和收集 | 委托回调 | 拾取通知 UI / 收集通知 | - --- ## 3. 类设计图 ```mermaid classDiagram - class AChildHoodCharacter { - -UInteractionManager* InteractionManager - -bool bIsInteracting - +SetInteracting(bool bValue) void - +IsInteracting() bool - +DoMove(float Right, float Forward) - +GetInteractionManager() UInteractionManager* + class ASystemDriver { + -FInteractionManager InteractionManager + -FEffectManager EffectManager + +GetInteractionManager() FInteractionManager& + +GetEffectManager() FEffectManager& + +BeginPlay() void + +Tick(float DeltaTime) void + +static Get(UWorld*) ASystemDriver* + Note: AInfo全局唯一,持有所有World级Manager + Note: 显式控制创建顺序和Tick顺序 + Note: Manager是普通C++类,不是UObject } - class UChildHoodGameInstance { - -TSet~FName~ CollectedItemIDs - -TMap~FName_bool~ DoorStates - +IsItemCollected(FName ItemId) bool - +MarkItemCollected(FName ItemId) void - +IsDoorOpen(FName DoorId) bool - +SetDoorOpen(FName DoorId, bool bIsOpen) void - +ResetAll() void - } - - class AChildHoodPlayerController { - +SetupInputComponent() void - +OnInteractPressed() void - +OnCancelPressed() void - +OnSwitchInteract(float Direction) void - } - - class UInteractionManager { + class FInteractionManager { -EInteractionState State - -EInteractPhase Phase - -TArray~UInteractableComponent*~ NearbyInteractables + -TArray~TWeakObjectPtr~UInteractableComponent~~ NearbyInteractables -int32 SelectedIndex - -UInteractableComponent* CurrentInteractable - -AActor* PushCameraActor - -UCameraComponent* PushCameraComp - -float TurnElapsed - -float WaitElapsed - -FTimerHandle AnimEventTimerHandle - -FTimerHandle AnimEndTimerHandle - -bool bAnimEventFired - -FString LastNarrativeText - +BeginInteract() void + -TWeakObjectPtr~UInteractableComponent~ CurrentInteractable + -TWeakObjectPtr~ACharacter~ PlayerCharacter + -FSelectedInfo CachedSelected + -TFunction~void~ OnInteractionFinished + +Initialize(ACharacter*) void + +BeginInteract() UInteractableComponent* +CancelInteract() void +SwitchTarget(int32 Direction) void +RegisterInteractable(UInteractableComponent*) void +UnregisterInteractable(UInteractableComponent*) void - #TickComponent(float, ELevelTick) void - -UpdateSelection() void - -UpdatePromptUI() void - -TickEnterPhase(float DeltaTime) void - -TickAnimLockPhase(float DeltaTime) void - -TickExitPhase(float DeltaTime) void - -TransitionFromEnter() void - -OnAnimLockComplete() void - -ExecuteAnimEvent() void - -ShowNarrativeText(FString Text) void - -HideNarrativeText() void - -PushCamera(FVector Target, float Duration) void - -ReturnCamera(float Duration) void - -CalculatePushPosition(UInteractableComponent*) FVector - +OnItemPickedUp FMLocalItemEvent - +OnItemFound FMLocalItemEvent + +GetState() EInteractionState + +HasSelection() bool + +GetSelectedPromptText() FString + +GetSelectedWorldPosition() FVector + -RefreshCachedSelection() void + Note: 普通C++类,非UObject + Note: 所有UObject引用用TWeakObjectPtr + Note: 纯事件驱动,无Tick + } + + class FEffectManager { + -FInteractEffect CurrentEffect + -FEffectContext CurrentContext + -float ElapsedTime + -int32 NextActionIndex + -bool bIsPlaying + -FTurnState TurnState + -TFunction~void~ OnEffectComplete + +PlayEffect(FInteractEffect, FEffectContext) void + +CancelEffect() void + +IsPlaying() bool + +Update(float DeltaTime) void + -ExecuteAction(FInteractAction, FEffectContext) void + -FinishEffect() void + Note: 普通C++类,非UObject + Note: 所有UObject引用用TWeakObjectPtr + Note: Tick由Driver显式调用 + } + + class UPersistenceSubsystem { + -TSet~FName~ CollectedItemIDs + -TMap~FName_bool~ DoorStates + +IsItemCollected(FName) bool + +MarkItemCollected(FName) void + +IsDoorOpen(FName) bool + +SetDoorOpen(FName, bool) void + +ResetAll() void } class UInteractableComponent { - -FInteractableData Data + #FInteractableData Data -USphereComponent* DetectionSphere -bool bIsConsumed - -bool bDoorIsOpen +GetInteractableData() FInteractableData - +GetPromptText() FString + +GetPromptText() virtual FString +IsConsumed() bool - +Consume() void + +Consume() virtual void + +GetWorldLocation() FVector + +RestorePersistentState(UPersistenceSubsystem*) virtual void + #OnDetectionOverlapBegin(...) void + #OnDetectionOverlapEnd(...) void + } + + class UDoorInteractableComponent { + -bool bDoorIsOpen +ToggleDoor() void +IsDoorOpen() bool - +GetWorldLocation() FVector - +RestorePersistentState(UChildHoodGameInstance*) void - #OnDetectionOverlapBegin(UPrimitiveComponent*, AActor*) void - #OnDetectionOverlapEnd(UPrimitiveComponent*, AActor*) void + +GetPromptText() FString + +RestorePersistentState(UPersistenceSubsystem*) void + } + + class AInteractableActor { + #UStaticMeshComponent* MeshComp + #UInteractableComponent* InteractableComp + +OnInteractStarted(UInteractableComponent*) virtual void + +OnInteractEnded(UInteractableComponent*) virtual void + } + + class ADoorActor { + +OnInteractStarted(UInteractableComponent*) void + } + + class UInteractionPromptComponent { + -TWeakObjectPtr~ASystemDriver~ Driver + -UInteractionPromptWidget* PromptWidget + -float PromptHeightOffset + +Initialize(ASystemDriver*) void + #TickComponent(float) void + Note: 只读 Manager 缓存,不直接访问任何 Component } class FInteractableData { +FName ItemId +FString DisplayName - +EInteractType Type - +EOperateSubType OperateSubType +float TriggerRadius +bool bOneShot +FString InteractVerb +FString InteractVerbAlt + +FInteractEffect InteractEffect + } + + class FInteractEffect { + +FString EffectName + +float TotalDuration + +bool bCancellable + +TArray~FInteractAction~ Actions + } + + class FInteractAction { + +EInteractActionType Type + +float TriggerTime + +UAnimMontage* Montage + +float PushDistance + +float PushDuration + +float ReturnDuration + +float TurnDuration +TArray~FString~ NarrativeTexts - +bool bUseCameraPush - +float CameraPushDistance - +float CameraPushDuration - +float PushWaitDelay - +UAnimMontage* InteractMontage - +float AnimDuration - +float AnimEventTimepoint - +EPostAction PostAction - +float CameraReturnDuration } - class AInteractableActor { - -UStaticMeshComponent* MeshComp - -UInteractableComponent* InteractableComp - +OnInteractStarted(UInteractableComponent*) void - +OnInteractEnded(UInteractableComponent*) void - } - - class APushCameraActor { - +UCameraComponent* CameraComp - } - - AChildHoodCharacter *-- UInteractionManager : 组合 - AChildHoodCharacter --> AChildHoodPlayerController : 关联 - UInteractionManager --> UInteractableComponent : 检测并驱动 - UInteractionManager o-- APushCameraActor : 聚合(引用) + ASystemDriver *-- FInteractionManager : 持有(值或unique_ptr) + ASystemDriver *-- FEffectManager : 持有(值或unique_ptr) + FInteractionManager ..> UPersistenceSubsystem : 依赖 + FEffectManager ..> UPersistenceSubsystem : 依赖 AInteractableActor *-- UInteractableComponent : 组合 - AInteractableActor *-- UStaticMeshComponent : 组合 - UInteractableComponent *-- FInteractableData : 组合(内含) - UInteractableComponent o-- USphereComponent : 聚合(引用子对象) - UInteractableComponent ..> UChildHoodGameInstance : 恢复持久状态 - UInteractionManager ..> UChildHoodGameInstance : 写入消耗/门状态 + ADoorActor --|> AInteractableActor : 继承 + UDoorInteractableComponent --|> UInteractableComponent : 继承 + ADoorActor *-- UDoorInteractableComponent : 组合 + UInteractableComponent *-- FInteractableData : 内含 + FInteractableData *-- FInteractEffect : 内含 + FInteractEffect *-- FInteractAction : 内含数组 + AChildHoodCharacter *-- UInteractionPromptComponent : 组合 + UInteractionPromptComponent ..> ASystemDriver : 只读Manager缓存 ``` -### 关系类型说明 - -| 关系类型 | 含义 | 本项目中的体现 | -|---------|------|--------------| -| 组合 `*--` | 生命周期绑定 | Character 持有 InteractionManager;InteractableActor 持有 InteractableComponent | -| 聚合 `o--` | 引用但非拥有 | InteractionManager 引用 PushCameraActor;InteractableComponent 引用 DetectionSphere(子对象) | -| 关联 `-->` | 持久引用 | Character 关联 PlayerController;InteractionManager 引用 InteractableComponent 列表 | -| 依赖 `..>` | 临时使用 | InteractableComponent 从 GameInstance 恢复状态;InteractionManager 向 GameInstance 写入消耗/门状态 | - ### 新增类 | 类名 | 类型 | 职责 | 位置 | |------|------|------|------| -| `UInteractionManager` | UActorComponent | 交互全流程状态机、检测选择、类型分发、摄像机/转向/UI 控制 | `Source/ChildHood/Interaction/` | -| `UInteractableComponent` | UActorComponent | 单个交互物配置数据、触发区域、运行时状态、跨场景持久状态恢复 | `Source/ChildHood/Interaction/` | -| `AInteractableActor` | AActor | 通用交互物 Actor 基类,持有 Mesh + InteractableComponent | `Source/ChildHood/Interaction/` | -| `APushCameraActor` | AActor | 推镜用临时摄像机,仅含 UCameraComponent | `Source/ChildHood/Interaction/` | -| `UChildHoodGameInstance` | UGameInstance | 跨关卡持久数据持有者:已收集物品 ID、门状态等 | `Source/ChildHood/` | +| `ASystemDriver` | AInfo | 全局唯一,持有所有 World 级 Manager,显式控制创建/Tick 顺序 | `Source/ChildHood/` | +| `FInteractionManager` | 普通 C++ | 检测列表、选中管理、顶层状态,纯事件驱动无 Tick | `Source/ChildHood/Interaction/` | +| `FEffectManager` | 普通 C++ | Effect Timeline 调度、Action 执行分发,由 Driver Tick 驱动 | `Source/ChildHood/Interaction/` | +| `UPersistenceSubsystem` | UGameInstanceSubsystem | 跨场景持久数据 | `Source/ChildHood/Interaction/` | +| `UInteractableComponent` | UActorComponent | 通用交互物配置+检测+运行时状态 | `Source/ChildHood/Interaction/` | +| `AInteractableActor` | AActor | 通用交互物基类 | `Source/ChildHood/Interaction/` | +| `UDoorInteractableComponent` | UActorComponent | 门:开关状态+动词切换+状态恢复 | `Source/ChildHood/Interaction/` | +| `ADoorActor` | AActor | 门:Mesh 旋转视觉反馈 | `Source/ChildHood/Interaction/` | +| `UInteractionPromptComponent` | UActorComponent | Prompt UI 显示/位置追踪 | `Source/ChildHood/Interaction/` | ### 修改类 | 类名 | 修改内容 | |------|---------| -| `AChildHoodCharacter` | 新增 `UInteractionManager*` 成员、`bIsInteracting` 标志、`SetInteracting()` / `GetInteractionManager()` 接口;`DoMove()` 添加交互中判断 | -| `AChildHoodPlayerController` | 新增 `IA_Interact` / `IA_Cancel` / `IA_SwitchInteract` InputAction 属性;`SetupInputComponent()` 添加交互输入绑定 | +| `AChildHoodCharacter` | 持有 `UInteractionPromptComponent`;输入回调通过 `ASystemDriver::Get()` 访问 Manager | +| `UChildHoodGameInstance` | 删除所有交互相关对象持有;仅保留 GameInstance 级基础设施 | +| `AChildHoodPlayerController` | 保留交互输入绑定 | -### 新增蓝图 +### 删除类 -| 蓝图名 | 类型 | 用途 | -|--------|------|------| -| `WBP_InteractionPrompt` | UMG Widget | 交互提示 UI(`[E] 查看旧冰箱`),屏幕空间悬浮 | -| `WBP_NarrativeText` | UMG Widget | 文本底框 UI(半透明背景 + 一句话文字) | -| `BP_Interactable` | Actor Blueprint | 基于 AInteractableActor 的可配置交互物蓝图,每个场景交互物为其实例 | +| 类名 | 原因 | +|------|------| +| 旧 `UInteractionManager` (UActorComponent) | 移入 GameInstance,重写为 UObject | --- ## 4. 时序图 -> ⚠️ 交互系统涉及 3+ 系统协调(交互→物品→收集),存在条件分支和类型分发,满足时序图绘制条件。 +### 4.1 拾取交互(地面物品) -### 4.1 完整交互流程(以观察类为例) +> 详见 `docs/technical/交互系统-拾取时序图.md`(基于 v1.0 架构更新版) ```mermaid sequenceDiagram autonumber - participant Player as 玩家 - participant PC as PlayerController - participant IM as InteractionManager - participant IC as InteractableComponent - participant Char as Character - participant PCA as PushCameraActor - participant UI as PromptUI + actor Player as 玩家 + participant Char as AChildHoodCharacter + participant Mgr as InteractionManager
(GameInstance) + participant Comp as UInteractableComponent + participant EFM as EffectManager + participant Persist as PersistenceSubsystem + participant Bag as BackpackSystem + participant Pres as PromptComponent + participant Driver as AInfo Driver - Note over IM: State: Free, 检测标记: true + Note over Player,Driver: === Phase 0: 进入检测范围 === + Player->>Char: 移动进入 TriggerRadius + Comp->>Mgr: OnOverlap → GetGI()->GetMgr()->RegisterInteractable(this) + Mgr->>Mgr: Sort by Dist2D, SelectedIndex = 0 - Player->>PC: 按 E 键 (IA_Interact) - activate PC - PC->>IM: BeginInteract() - deactivate PC + Note over Player,Driver: === Phase 1: Prompt 显示 (Pres 每帧 Polling) === + loop 每帧 Tick + Pres->>Mgr: GetState() → Free + Pres->>Mgr: GetSelectedInteractable() → Comp + Pres->>Comp: GetPromptText() → "[E] 拾取 旧橡皮" + Pres->>Comp: GetWorldLocation() → FVector + Pres->>Pres: UpdatePromptWidget() + end - activate IM - IM->>IM: State=Interacting, Phase=Enter - IM->>Char: SetInteracting(true) - IM->>UI: HidePrompt() + Note over Player,Driver: === Phase 2: 按 E 触发 Effect === + Player->>Char: 按 E + Char->>Mgr: GetGI()->GetMgr()->BeginInteract() + Mgr->>Mgr: State = Executing + Mgr->>Char: SetInteracting(true) + Mgr->>EFM: PlayEffect(Comp.Data.InteractEffect, Context) + Note right of EFM: Effect: "拾取地面物品"
T=0.0: TurnToTarget
T=0.0: PlayMontage
T=0.6: Consume+Hide+Backpack
T=1.0: PickupNotification
Total: 1.5s - Note over IM: Phase: Enter — 转向 + 推镜 + Note over Player,Driver: === Phase 3: Presentation 检测状态变化 === + Pres->>Mgr: Tick: GetState() → Executing (变化!) + Pres->>Pres: HidePrompt() - IM->>IM: Tick: RInterpTo 转向目标 - IM->>PCA: SetActorLocation(TargetPushPos) - IM->>PC: SetViewTargetWithBlend(PCA, 0.5s, EaseOut) + Note over Player,Driver: === Phase 4: Effect 执行 (Driver Tick 驱动) === + loop 每帧 Driver→EFM.Update(DeltaTime) + EFM->>EFM: ElapsedTime += DeltaTime + EFM->>EFM: 检查 TriggerTime 触发 Action + EFM->>Char: TurnToTarget: RInterpTo 转向 + end - Note over IM: 推镜完成后等待 0.2s + Note over EFM: T=0.0: TurnToTarget + PlayMontage + EFM->>Char: Montage_Play(PickupMontage) - IM->>IM: Phase=Active, 显示叙事文本 - IM->>IM: ShowNarrativeText(随机选取) + Note over EFM: T=0.6: Consume + HideActor + AddToBackpack + EFM->>Comp: Consume() + Comp-->>Comp: bIsConsumed=true, DisableSphere + EFM->>Persist: MarkItemCollected("rubber_01") + EFM->>Char: SetActorHiddenInGame(true) + EFM->>Bag: AddItem("rubber_01", "旧橡皮") + Pres->>Comp: Tick: IsConsumed() → true + Pres->>Char: SetActorHiddenInGame(true) - Note over IM: Phase: Active — 等待玩家关闭 + Note over EFM: T=1.0: ShowPickupNotification + EFM->>Pres: 显示 "捡到了 [旧橡皮]!" - Player->>PC: 按 E/ESC (IA_Cancel) - PC->>IM: CancelInteract() - IM->>IM: HideNarrativeText() (淡出 0.2s) - IM->>PC: SetViewTargetWithBlend(Char, 0.3s, EaseIn) - - Note over IM: Phase: Exit — 相机回位 - - IM->>Char: SetInteracting(false) - IM->>IM: State=Free - deactivate IM - - Note over IM: 回到 Free,若仍在范围内则重新显示提示 -``` - -### 4.2 拾取流程(跨系统通知) - -```mermaid -sequenceDiagram - autonumber - participant Player as 玩家 - participant IM as InteractionManager - participant IC as InteractableComponent - participant Char as Character - participant ItemSys as 物品系统(委托) - - Player->>IM: BeginInteract() [Type=Pickup] - activate IM - - IM->>IM: State=Interacting, Phase=Enter - IM->>Char: SetInteracting(true) - IM->>IM: Tick: 转向目标 - - Note over IM: Phase: AnimLock — 播放拾取动画 - - IM->>Char: PlayMontage(弯腰拾取) - IM->>IM: 设置计时器: AnimEvent@60% - - Note over IM: 动画播放至 60% - - IM->>IC: Consume() (标记已消耗) - IM->>IC: 通知 Actor 隐藏 Mesh - IM->>ItemSys: OnItemPickedUp.Broadcast(ItemId, "杂物") - activate ItemSys - ItemSys-->>IM: (物品系统自行处理) - deactivate ItemSys - - Note over IM: 动画自然播完 - - IM->>Char: SetInteracting(false) - IM->>IC: 注销检测 - IM->>IM: State=Free - deactivate IM + Note over Player,Driver: === Phase 5: Effect 完成 === + EFM->>EFM: ElapsedTime >= TotalDuration + EFM->>Mgr: OnEffectComplete 回调 + Mgr->>Mgr: State = Free + Mgr->>Char: SetInteracting(false) + Pres->>Mgr: Tick: GetState() → Free (变化!) ``` --- @@ -394,526 +458,344 @@ sequenceDiagram ### 枚举 ```cpp -// 交互类型 -UENUM(BlueprintType) -enum class EInteractType : uint8 -{ - Examine, // 观察 - Pickup, // 拾取 - Operate, // 操作 - Talk // 对话 -}; - -// 操作子类型(仅 Operate 类型有效) -UENUM(BlueprintType) -enum class EOperateSubType : uint8 -{ - Door, // 门(开关切换) - Search // 翻找 -}; - -// 交互系统顶层状态 +// 交互系统顶层状态(简化为 2 阶段) UENUM(BlueprintType) enum class EInteractionState : uint8 { - Free, // 自由状态 - Interacting, // 交互中 - SubSystem, // v0.1 预留 - Cutscene // v0.1 预留 + Free, // 自由状态,检测/选择/Prompt + Executing, // 执行 Effect 中 }; -// Interacting 子阶段 +// Action 类型 UENUM(BlueprintType) -enum class EInteractPhase : uint8 +enum class EInteractActionType : uint8 { - None, - Enter, // 进入:转向 + 推镜 - AnimLock, // 动画锁定:等待动画/计时完成 - Active, // 活跃:等待玩家输入(E/ESC) - Exit // 退出:回位 + 淡出 -}; - -// 交互结束后动作 -UENUM(BlueprintType) -enum class EPostAction : uint8 -{ - Return, // 回到 Free - Minigame, // v0.1 预留 - Dialogue, // v0.1 预留 - SceneChange // v0.1 预留 + 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 = "显示叙事文本"), + ShowPickupNotification UMETA(DisplayName = "显示拾取通知"), + ToggleDoor UMETA(DisplayName = "切换门状态"), + PlayDialogue UMETA(DisplayName = "播放对话"), + PlayStory UMETA(DisplayName = "播放小剧情"), + BroadcastEvent UMETA(DisplayName = "广播逻辑事件"), }; ``` -### 配置数据结构 +### FInteractAction — 原子操作定义 + +```cpp +USTRUCT(BlueprintType) +struct FInteractAction +{ + GENERATED_BODY() + + // === 编排 === + + 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 Montage; + + // --- PlayMontageOnTarget --- + UPROPERTY(EditAnywhere, meta = (EditCondition = "Type==EInteractActionType::PlayMontageOnTarget", EditConditionHides)) + TObjectPtr TargetMontage; + + // --- CameraPush --- + 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 NarrativeTexts; +}; +``` + +### FInteractEffect — Effect 定义 + +```cpp +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 Actions; +}; +``` + +### FInteractableData — 简化后的配置 ```cpp -// 可交互物配置数据 USTRUCT(BlueprintType) struct FInteractableData { GENERATED_BODY() // === 基本信息 === - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic") - FName ItemId; // 唯一ID(如 "fridge_home") + UPROPERTY(EditAnywhere, Category = "Basic") + FName ItemId; - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic") - FString DisplayName; // 显示名称(如 "旧冰箱") - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic") - EInteractType Type = EInteractType::Examine; - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic", - meta = (EditCondition = "Type == EInteractType::Operate", EditConditionHides)) - EOperateSubType OperateSubType = EOperateSubType::Door; + UPROPERTY(EditAnywhere, Category = "Basic") + FString DisplayName; // === 触发 === - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Trigger") - float TriggerRadius = 150.0f; // 触发半径(cm),默认 1.5m + UPROPERTY(EditAnywhere, Category = "Trigger", meta = (ClampMin = "50.0", ClampMax = "500.0")) + float TriggerRadius = 150.f; - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Trigger") - bool bOneShot = false; // 一次性消耗 + UPROPERTY(EditAnywhere, Category = "Trigger") + bool bOneShot = false; // === 提示文本 === - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Prompt") - FString InteractVerb; // 动作词(如 "查看"、"拾取"、"打开") + UPROPERTY(EditAnywhere, Category = "Prompt") + FString InteractVerb; - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Prompt", - meta = (EditCondition = "Type == EInteractType::Operate && OperateSubType == EOperateSubType::Door")) - FString InteractVerbAlt; // 备选动词(如 "关上",门打开时使用) + UPROPERTY(EditAnywhere, Category = "Prompt") + FString InteractVerbAlt; // 门关闭/打开时使用 - // === 叙事/对话文本 === - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Text") - TArray NarrativeTexts; // 文本列表,随机选取,不重复上次 + // === 交互 Effect === + UPROPERTY(EditAnywhere, Category = "Effect") + FInteractEffect InteractEffect; - // === 摄像机推镜 === - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera") - bool bUseCameraPush = false; - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera", - meta = (EditCondition = "bUseCameraPush")) - float CameraPushDistance = 80.0f; // 推镜距离(cm) - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera", - meta = (EditCondition = "bUseCameraPush")) - float CameraPushDuration = 0.5f; - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera", - meta = (EditCondition = "bUseCameraPush")) - float CameraReturnDuration = 0.3f; - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera", - meta = (EditCondition = "bUseCameraPush")) - float PushWaitDelay = 0.2f; // 推镜完成后到文本显示的等待(秒) - - // === 动画 === - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim") - UAnimMontage* InteractMontage = nullptr; // 交互动画蒙太奇(可选) - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim") - float AnimDuration = 1.0f; // 无蒙太奇时的动画持续时间(秒) - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim") - float AnimEventTimepoint = 0.6f; // 动画事件触发点(0-1 比例) - - // === 结束动作 === - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Post") - EPostAction PostAction = EPostAction::Return; + // ❌ 已删除(由 Effect 内 Action 替代): + // EInteractType Type, EOperateSubType OperateSubType + // bUseCameraPush, CameraPushDistance/Duration, PushWaitDelay, CameraReturnDuration + // InteractMontage, AnimDuration, AnimEventTimepoint + // NarrativeTexts, EPostAction PostAction }; ``` -### 委托声明 +### FEffectContext — 运行时上下文 ```cpp -// 物品拾取/获得事件 +USTRUCT() +struct FEffectContext +{ + TWeakObjectPtr Instigator; + TWeakObjectPtr TargetComp; + TWeakObjectPtr TargetActor; +}; +``` + +### 委托 + +```cpp +// 物品消耗事件(逻辑事件,Persistence/Backpack 订阅) DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams( FMLocalItemEvent, FName, ItemId, const FString&, DisplayName, const FName&, Category ); + +// Effect 完成回调 +DECLARE_DELEGATE(FOnEffectComplete); ``` --- -## 6. 行为规则 → 实现方案映射 +## 6. 行为规则 → Effect 配置映射 -对执行文档中每条行为规则,逐一说明实现方案。 +执行文档中每条行为规则,现在通过 **预定义 Effect 配置** 实现。交互物实例在编辑器中配置 Effect,运行时由 EffectManager 执行。 -### 一、通用规则 — 触发条件 +### Effect 配置模板 -#### 规则 1: 距离 ≤ 触发半径 + Free 状态 + 未消耗 → 可触发 +#### 模板 A:观察类 Effect -**实现**:`UInteractableComponent` 的 `USphereComponent` 碰撞处理注册/注销。`BeginInteract()` 检查三个前置条件。 +对应执行案规则 E1-E6。 -```cpp -void UInteractionManager::BeginInteract() -{ - if (State != EInteractionState::Free) return; - if (!bIsInDetectionRange) return; - if (!CurrentInteractable || CurrentInteractable->IsConsumed()) return; - // ... 进入交互流程 -} +``` +Effect: "观察物品" +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=["冰箱嗡嗡响。里面有冰棒!"] +0.7s | (等待用户输入,ShowNarrative 持续显示) ``` -**可调参数**:TriggerRadius(每个交互物独立配置,默认 150cm,范围 100-300cm) +> ShowNarrative Action 执行后进入"等待用户输入"状态,用户按 E/ESC 后 Effect 继续。 +> 此行为通过在 ShowNarrative Action 中标记 `bWaitForInput=true` 实现(后续版本扩展)。 +> v0.1 简化:ShowNarrative 不等待,用 Effect TotalDuration 控制总时长。 ---- +#### 模板 B:拾取类 Effect -#### 规则 2-5: 提示 UI 显示/隐藏 +对应执行案规则 P1-P8。 -**实现**: +``` +Effect: "拾取地面物品" +TotalDuration: 1.5s, Cancellable: false -- **进入范围**:`UInteractableComponent::OnDetectionOverlapBegin` → 调用 `Character->GetInteractionManager()->RegisterInteractable(this)` → InteractionManager 添加到 NearbyInteractables 并排序 → 更新选中 → 显示提示 UI(淡入 0.2s) -- **提示格式**:`GetPromptText()` 返回 `"[E] " + InteractVerb + " " + DisplayName`;门类型根据 bDoorIsOpen 选择 InteractVerb 或 InteractVerbAlt -- **提示位置**:每帧 `ProjectWorldLocationToScreen(InteractableLocation + Z偏移)` 更新 Widget 位置 -- **离开范围**:`OnDetectionOverlapEnd` → `UnregisterInteractable` → 若为当前选中则切换到下一个;若列表空则淡出提示 - -```cpp -FString UInteractableComponent::GetPromptText() const -{ - 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); -} +TriggerTime | Action | 参数 +----------- | --------------------- | ---- +0.0s | TurnToTarget | TurnDuration=0.2 +0.0s | PlayMontage | Montage=AM_Pickup +0.6s | Consume | +0.6s | HideActor | +0.6s | AddToBackpack | +1.0s | ShowPickupNotification| ``` -**可调参数**:PromptFadeInDuration (0.2s, 0.1-0.5s), PromptFadeOutDuration (0.2s, 0.1-0.5s), PromptHeightOffset (交互物上方偏移) +#### 模板 C:门操作 Effect ---- +对应执行案规则 D1-D6。 + +``` +Effect: "操作门" +TotalDuration: 1.0s, Cancellable: false + +TriggerTime | Action | 参数 +----------- | ------------- | ---- +0.0s | TurnToTarget | TurnDuration=0.2 +0.0s | PlayMontage | Montage=AM_PushDoor +0.3s | ToggleDoor | +``` + +#### 模板 D:翻找类 Effect + +对应执行案规则 S1-S7。 + +``` +Effect: "翻找废纸箱" +TotalDuration: 3.5s, Cancellable: false + +TriggerTime | Action | 参数 +----------- | --------------------- | ---- +0.0s | TurnToTarget | TurnDuration=0.2 +0.0s | CameraPush | PushDistance=60, PushDuration=0.5 +0.1s | PlayMontage | Montage=AM_Search +1.8s | Consume | +1.8s | AddToBackpack | +1.9s | CameraReturn | ReturnDuration=0.3 +2.2s | ShowNarrative | Texts=["在废纸箱里翻到了..."] +3.2s | ShowPickupNotification| +``` + +#### 模板 E:对话类 Effect + +对应执行案规则 K1-K6。 + +``` +Effect: "NPC对话" +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"] +``` + +### 通用规则实现 + +#### 规则 1: 触发条件 + +**实现**:`UInteractableComponent::OnDetectionOverlapBegin` → `GetGI()->GetMgr()->RegisterInteractable(this)`。Manager 的 `BeginInteract()` 检查 State==Free、SelectedIndex 有效、!IsConsumed()。 + +#### 规则 2-5: Prompt UI 显示/隐藏 + +**实现**:`UInteractionPromptComponent` 每帧 Tick 中: +- 读 `Manager->GetState()` == Free → 显示 +- 读 `Manager->HasSelection()` → 有选中? +- 读 `Manager->GetSelectedPromptText()` + `GetSelectedWorldPosition()` → 更新 Widget +- State 变为 Executing → 隐藏 +- **不直接访问任何 Component**,所有数据来自 Manager 缓存 + +Manager 在 `RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget` 中调用 `RefreshCachedSelection()` 从 Component 读取并缓存。 #### 规则 6-10: 多交互物选择与切换 -**实现**: +**实现**:同 v0.1,但 Manager 在 GameInstance 中。`RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget` / `UpdateSelection` 逻辑不变。 -- **默认选中最近**:`UpdateSelection()` 按 `FVector::DistSquared2D(CharacterFoot, InteractableLocation)` 排序,`SelectedIndex = 0` -- **距离判定点**:角色脚底中心(`GetActorLocation()`,已在地面)到交互物注册点的水平 2D 距离 -- **Tab/滚轮切换**:`SwitchTarget(Direction)` 调整 SelectedIndex(循环),`Direction = +1` (下一个) / `-1` (上一个) -- **切换时 UI 立即更新**:SwitchTarget 内直接调用 `UpdatePromptUI()` 设置新内容 +#### 规则 11-14: 交互触发与锁定 + +**实现**:Character 输入回调中协调两个系统: ```cpp -void UInteractionManager::SwitchTarget(int32 Direction) +void AChildHoodCharacter::OnInteract() { - if (NearbyInteractables.Num() == 0) return; - if (State != EInteractionState::Free) return; // Interacting 时忽略 - SelectedIndex = (SelectedIndex + Direction + NearbyInteractables.Num()) % NearbyInteractables.Num(); - UpdatePromptUI(); + auto* Driver = ASystemDriver::Get(GetWorld()); + auto& Mgr = Driver->GetInteractionManager(); + auto& EFM = Driver->GetEffectManager(); + + // 1. Manager:能交互吗?选中的是谁? + auto* Selected = Mgr.BeginInteract(); + if (!Selected) return; + + SetInteracting(true); + + // 2. EffectManager:执行 Effect + FEffectContext Ctx; + Ctx.Instigator = this; + Ctx.TargetComp = Selected; + Ctx.TargetActor = Selected->GetOwner(); + EFM.PlayEffect(Selected->GetInteractableData().InteractEffect, Ctx); } ``` ---- +`BeginInteract()` 返回选中的 Component(或 nullptr 拒绝),设置 `State = Executing`。Effect 执行期间所有输入被忽略(CancelInteract 检查 bCancellable)。 -#### 规则 11-14: 交互触发与锁定规则 +Effect 完成通过 Driver 中 `std::function` 连线:`EffectManager.OnEffectComplete → InteractionManager.OnInteractionFinished()`。 -**实现**: +#### 规则 15-16: 交互结束 -- **E 键触发**:PlayerController 的 IA_Interact 回调 → `IM->BeginInteract()` -- **Interacting 状态下**:`Char->SetInteracting(true)` → `DoMove()` 中 `if (bIsInteracting) return;` 屏蔽移动 -- **Tab/滚轮忽略**:`SwitchTarget()` 开头检查 `if (State != EInteractionState::Free) return;` -- **E/ESC 在 AnimLock 时忽略**:`CancelInteract()` 检查 `if (Phase == EInteractPhase::AnimLock) return;`;AnimLock 中 `BeginInteract()` 因 State != Free 也被拒绝 +**实现**:`EffectManager::FinishEffect()` → `Manager->OnEffectComplete()` → `State = Free` + `Char->SetInteracting(false)`。Prompt 组件检测到 Free 后恢复显示。 ---- +### 边界情况实现 -#### 规则 15-16: 交互结束规则 +#### B1: AnimLock 期间所有按键忽略 -**实现**: +**实现**:Effect 的 `bCancellable = false` 时,`CancelInteract()` 被拒绝。拾取和门操作 Effect 均为不可取消。 -- **结束回到 Free**:Exit 阶段完成后 `State = Free` -- **检测标记恢复**:回到 Free 后检查 `NearbyInteractables.Num() > 0`,若有则立即更新提示 UI -- **oneShot 消耗**:`Consume()` 设置 `bIsConsumed = true`,注销检测区域(`DetectionSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision)`),从 NearbyInteractables 中移除 -- **跨场景持久化**:`Consume()` 同时调用 `GameInstance->MarkItemCollected(ItemId)` 写入持久层,确保场景切换后仍可恢复已消耗状态 +#### B2: oneShot 消耗后范围失效 ---- +**实现**:`Consume()` 设置 `bIsConsumed = true`,禁用 DetectionSphere。UnregisterInteractable 触发时,Manager 检查 `State == Executing` 且移除的是 `CurrentInteractable` → 仅从 NearbyInteractables 移除,延迟清理 CurrentInteractable。 -#### 规则 17: 通用文本显示 UI - -**实现**:`WBP_NarrativeText` UMG Widget。 - -- **位置**:Anchors 设为屏幕中心偏下(Vertical: 0.6, Horizontal: 0.5),通过 Padding 微调 -- **底框**:Border 组件,半透明黑色背景(默认 60% 透明度),圆角,尺寸随文字内容自适应 -- **文字**:TextBlock,白色,居中 -- **显示方式**:整句设置 Text,触发 FadeIn 动画(0.2s) -- **淡出**:触发 FadeOut 动画(0.2s),完成后隐藏 - -**可调参数**:TextBgOpacity (60%, 40-80%), TextFadeIn (0.2s, 0.1-0.3s), TextFadeOut (0.2s, 0.1-0.3s), TextVerticalOffset (屏幕高度 10%, 5-20%) - ---- - -### 二、观察类(Examine) - -#### 规则 E1-E3: 转向 + 推镜(同时开始)+ 等待 - -**实现**:Enter 阶段 Tick 中并行处理: - -```cpp -void UInteractionManager::TickEnterPhase(float DeltaTime) -{ - // 1. 转向插值 - TurnElapsed += DeltaTime; - FRotator TargetRot = CalculateTurnRotation(CurrentInteractable); - float TurnAlpha = FMath::Clamp(TurnElapsed / TurnDuration, 0.f, 1.f); - ACharacter* Char = Cast(GetOwner()); - Char->SetActorRotation(FMath::RInterpTo(Char->GetActorRotation(), TargetRot, DeltaTime, 1.f/TurnDuration)); - - // 2. 推镜(一次性发起,SetViewTargetWithBlend 自带缓动) - if (CurrentInteractable->GetInteractableData().bUseCameraPush && !bPushStarted) - { - FVector PushPos = CalculatePushPosition(CurrentInteractable); - PushCamera(PushPos, CurrentInteractable->GetInteractableData().CameraPushDuration); - bPushStarted = true; - } - - // 3. 判断 Enter 阶段完成(转向完成 + 推镜已发起) - bool bTurnComplete = TurnElapsed >= TurnDuration; - bool bPushComplete = !CurrentInteractable->GetInteractableData().bUseCameraPush || bPushStarted; - if (bTurnComplete && bPushComplete) - { - // 推镜后需要额外等待 - if (CurrentInteractable->GetInteractableData().bUseCameraPush && CurrentInteractable->GetInteractableData().PushWaitDelay > 0.f) - { - WaitElapsed += DeltaTime; - if (WaitElapsed >= CurrentInteractable->GetInteractableData().PushWaitDelay) - TransitionFromEnter(); - } - else - { - TransitionFromEnter(); - } - } -} -``` - -**推镜目标计算**: - -```cpp -FVector UInteractionManager::CalculatePushPosition(UInteractableComponent* Interactable) -{ - const FInteractableData& Data = Interactable->GetInteractableData(); - FVector InteractPos = Interactable->GetWorldLocation(); - FVector CharPos = GetOwner()->GetActorLocation(); - FVector Dir = (InteractPos - CharPos).GetSafeNormal2D(); - // 交互物前方(面向角色的一侧),推镜距离 - FVector TargetPos = InteractPos - Dir * Data.CameraPushDistance; - // 高度略低于交互物中心(微仰视) - TargetPos.Z = InteractPos.Z * 0.8f; - return TargetPos; -} -``` - -**推镜执行**: - -```cpp -void UInteractionManager::PushCamera(FVector Target, float Duration) -{ - if (!PushCameraActor) return; - FVector LookTarget = CurrentInteractable->GetWorldLocation(); - FRotator LookAtRot = (LookTarget - Target).Rotation(); - PushCameraActor->SetActorLocationAndRotation(Target, LookAtRot); - APlayerController* PC = GetWorld()->GetFirstPlayerController(); - if (PC) - { - PC->SetViewTargetWithBlend(PushCameraActor, Duration, VTBlend_EaseOut); - } -} -``` - -**可调参数**:TurnDuration (0.2s, 0.1-0.4s), CameraPushDistance (80-100cm, 50-200cm), CameraPushDuration (0.5s, 0.3-1.0s), PushWaitDelay (0.2s, 0.1-0.5s) - ---- - -#### 规则 E4-E6: 文本显示 → 等待关闭 → 回位 → 可重复交互 - -**实现**: - -- `TransitionFromEnter()` 对 Examine 类型:`Phase = Active`,调用 `ShowNarrativeText(随机文本)` -- CancelInteract() 在 Active 阶段触发:`HideNarrativeText()` → `Phase = Exit` → `ReturnCamera(ReturnDuration, EaseIn)` -- 可重复:Examine 默认 bOneShot = false,不调用 Consume() -- 文本不重复上次:`PickRandomNarrative()` 排除 LastNarrativeText - -```cpp -FString UInteractionManager::PickRandomNarrative(const TArray& Texts) -{ - if (Texts.Num() == 0) return TEXT(""); - if (Texts.Num() == 1) return Texts[0]; - FString Candidate; - do { - Candidate = Texts[FMath::RandRange(0, Texts.Num() - 1)]; - } while (Candidate == LastNarrativeText && Texts.Num() > 1); - LastNarrativeText = Candidate; - return Candidate; -} -``` - -**可调参数**:CameraReturnDuration (0.3s, 0.2-0.5s) - ---- - -### 三、拾取类(Pickup) - -#### 规则 P1-P8: 转向 → 动画(AnimLock) → 物品消失 + 通知 → 自动结束 → 一次性消耗 - -**实现**: - -- **Enter**:仅转向,无推镜(`bUseCameraPush = false`)。转向完成 → `Phase = AnimLock` -- **AnimLock**:播放蒙太奇(如有)或启动计时器。设置两个计时器: - -```cpp -void UInteractionManager::EnterAnimLockPhase() -{ - const FInteractableData& Data = CurrentInteractable->GetInteractableData(); - float TotalAnimTime = Data.InteractMontage ? Data.InteractMontage->GetPlayLength() : Data.AnimDuration; - - // 播放蒙太奇(如有) - if (Data.InteractMontage) - { - ACharacter* Char = Cast(GetOwner()); - if (Char && Char->GetMesh()) - Char->GetMesh()->GetAnimInstance()->Montage_Play(Data.InteractMontage); - } - - // 动画事件计时器(如 60% 时物品消失) - float EventTime = TotalAnimTime * Data.AnimEventTimepoint; - GetWorld()->GetTimerManager().SetTimer(AnimEventTimerHandle, this, &UInteractionManager::ExecuteAnimEvent, EventTime, false); - - // 动画结束计时器 - GetWorld()->GetTimerManager().SetTimer(AnimEndTimerHandle, this, &UInteractionManager::OnAnimLockComplete, TotalAnimTime, false); - - bAnimEventFired = false; -} -``` - -- **ExecuteAnimEvent(60%)**: - ```cpp - void UInteractionManager::ExecuteAnimEvent() - { - bAnimEventFired = true; - const FInteractableData& Data = CurrentInteractable->GetInteractableData(); - // Pickup: 物品消失 + 通知物品系统 - CurrentInteractable->Consume(); - if (AInteractableActor* Owner = Cast(CurrentInteractable->GetOwner())) - Owner->SetActorHiddenInGame(true); - OnItemPickedUp.Broadcast(Data.ItemId, Data.DisplayName, FName("杂物")); - } - ``` -- **OnAnimLockComplete**:Pickup 直接 `State = Free`(无 Active / Exit,因无摄像机/UI 需清理) -- **不支持手动关闭**:AnimLock 阶段 CancelInteract() 被拒绝 -- **相机不动**:`bUseCameraPush = false` -- **oneShot**:`Consume()` 已处理 - -**可调参数**:AnimDuration (1.0s, 0.8-1.5s), AnimEventTimepoint (0.6, 0.5-0.7) - ---- - -### 四、操作类 — 门(Operate-Door) - -#### 规则 D1-D6: 转向 → 动画(AnimLock) → 门状态切换 → 自动结束 → 可重复交互 - -**实现**: - -- **Enter**:仅转向,无推镜。转向完成 → `Phase = AnimLock` -- **AnimLock**:播放推门蒙太奇或计时器。`AnimEventTimepoint = 0.4` 时门状态切换: - ```cpp - // ExecuteAnimEvent 中的 Door 分支 - if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door) - { - CurrentInteractable->ToggleDoor(); // bDoorIsOpen = !bDoorIsOpen - // 持久化门状态到 GameInstance - if (UChildHoodGameInstance* GI = GetWorld()->GetGameInstance()) - { - GI->SetDoorOpen(Data.ItemId, CurrentInteractable->IsDoorOpen()); - } - // 通知 Actor 处理门 Mesh 旋转 - if (AInteractableActor* Owner = Cast(CurrentInteractable->GetOwner())) - Owner->OnInteractStarted(CurrentInteractable); - } - ``` -- **OnAnimLockComplete**:Door 直接 `State = Free` -- **可重复**:Door 默认 bOneShot = false,不 Consume() -- **提示文字变化**:`GetPromptText()` 根据 `bDoorIsOpen` 返回不同动词 - -**可调参数**:AnimDuration (0.8s, 0.5-1.2s), AnimEventTimepoint (0.4, 0.3-0.5) - ---- - -### 五、操作类 — 翻找(Operate-Search) - -#### 规则 S1-S7: 转向 + 推镜 → 动画(AnimLock) → 结果判定 → 等待关闭 → 回位 → oneShot 不消失 - -**实现**: - -- **Enter**:转向 + 推镜(`bUseCameraPush = true`,`CameraPushDistance = 60cm`)。转向+推镜完成 → `Phase = AnimLock` -- **AnimLock**:播放翻找蒙太奇或计时器(`AnimDuration = 1.5s`) -- **OnAnimLockComplete**:结果判定 - ```cpp - // v0.1 废纸箱必定有物品 - CurrentInteractable->Consume(); // oneShot,但 Actor 不隐藏(视觉仍在) - OnItemFound.Broadcast(Data.ItemId, Data.DisplayName, FName("杂物")); - Phase = EInteractPhase::Active; // 等待玩家按 E/ESC - // 有物品时不显示文本底框 - ``` -- **Active**:玩家按 E/ESC → `Phase = Exit` → 相机回位 → `State = Free` -- **无物品分支(架构预留)**:`ShowNarrativeText("什么都没找到。")` → Active → Exit -- **翻找后不消失**:`Consume()` 仅设置 bIsConsumed + 禁用检测,不调用 `SetActorHiddenInGame` - -**可调参数**:CameraPushDistance (60cm, 40-100cm), AnimDuration (1.5s, 1.0-2.0s) - ---- - -### 六、对话类(Talk) - -#### 规则 K1-K6: 转向 + 推镜 → 等待 → 文本 → 等待关闭 → 回位 - -**实现**:与观察类流程完全一致,区别仅在文本来源(NPC 对话内容而非小孩想法)。 - -- **Enter**:转向 + 推镜(`CameraPushDistance = 80-100cm`)+ 等待 0.2s -- **Active**:`ShowNarrativeText(随机对话文本)` → 等待 E/ESC -- **Exit**:淡出文本 → 相机回位 → Free -- **可重复**:Talk 默认 bOneShot = false - ---- - -### 七、边界情况 - -#### 规则 B1: AnimLock 期间所有按键忽略 - -**实现**:`BeginInteract()` 检查 `State != Free` 直接返回;`CancelInteract()` 检查 `Phase == AnimLock` 直接返回;`SwitchTarget()` 检查 `State != Free` 直接返回 - -#### 规则 B2: oneShot 消耗后范围失效 - -**实现**:`Consume()` 内调用 `DetectionSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision)` + 从 NearbyInteractables 移除 + 若为当前选中则切换 - -#### 规则 B3: 推镜期间角色不移动 - -**实现**:`SetInteracting(true)` 已屏蔽 DoMove,覆盖此场景 - -#### 规则 B4: 反复进出范围无累积副作用 - -**实现**:`RegisterInteractable` 做去重检查(`if (NearbyInteractables.Contains(Interactable)) return;`),且提示 UI 始终只显示当前选中物体的一条 - -#### 规则 B5: 切换+按 E 以切换后为准 - -**实现**:`SwitchTarget()` 是同步操作,E 键回调在同帧或之后帧触发时,`CurrentInteractable` 已是切换后的新值 - -#### 规则 B6: 恰好在边界 → 在范围内 - -**实现**:SphereComponent Overlap 使用 `≤` 语义(UE5 默认行为),满足要求 - ---- - -### 八、类型→阶段流转汇总 - -> 汇总四种交互类型的完整阶段流转,供实现参考。 - -| 类型 | Enter | AnimLock | Active | Exit | 备注 | -|------|-------|----------|--------|------|------| -| Examine | 转向 + 推镜 + 等待 | — | 显示文本, 等 E/ESC | 淡出 + 回位 | 可重复, 随机文本 | -| Pickup | 转向 | 播放动画, 60%消失+通知 | — | — | 直接回 Free, oneShot | -| Operate-Door | 转向 | 播放动画, 40%切换门 | — | — | 直接回 Free, 可重复 | -| Operate-Search | 转向 + 推镜 | 播放动画 | 通知物品系统, 等 E/ESC | 回位 | oneShot 不消失 | -| Talk | 转向 + 推镜 + 等待 | — | 显示文本, 等 E/ESC | 淡出 + 回位 | 可重复, 随机文本 | +#### B3-B6: 同 v0.1 逻辑,不受架构变更影响。 --- @@ -921,15 +803,15 @@ void UInteractionManager::EnterAnimLockPhase() | 资源类型 | 名称 | 数量 | 规格 | 触发时机/用途 | |----------|------|------|------|---------------| -| 动画蒙太奇 | AM_Pickup | 1 | 弯腰拾取,约 1.0s | Pickup 交互 AnimLock 阶段 | -| 动画蒙太奇 | AM_PushDoor | 1 | 伸手推/拉门,约 0.8s | Door 交互 AnimLock 阶段 | -| 动画蒙太奇 | AM_Search | 1 | 弯腰翻找,约 1.5s | Search 交互 AnimLock 阶段 | -| UI Widget | WBP_InteractionPrompt | 1 | 轻量悬浮提示,含 TextBlock | 进入交互范围时显示 | -| UI Widget | WBP_NarrativeText | 1 | 半透明底框 + TextBlock,带淡入淡出动画 | 观察/对话/翻找无结果时显示 | +| 动画蒙太奇 | AM_Pickup | 1 | 弯腰拾取,约 1.0s | 拾取 Effect PlayMontage Action | +| 动画蒙太奇 | AM_PushDoor | 1 | 伸手推/拉门,约 0.8s | 门操作 Effect PlayMontage Action | +| 动画蒙太奇 | AM_Search | 1 | 弯腰翻找,约 1.5s | 翻找 Effect PlayMontage Action | +| UI Widget | WBP_InteractionPrompt | 1 | 悬浮提示 + TextBlock | Prompt 组件 | +| UI Widget | WBP_NarrativeText | 1 | 半透明底框 + TextBlock | ShowNarrative Action | +| UI Widget | WBP_PickupNotification | 1 | 拾取通知弹窗 | ShowPickupNotification Action | | 输入资产 | IA_Interact / IA_Cancel / IA_SwitchInteract | 3 | InputAction 资产 | 交互输入映射 | -| 输入映射 | IMC_Default(追加绑定) | 1 | 在现有 IMC 中添加交互按键映射 | E/ESC/Tab/滚轮 | -> v0.1 原型阶段:动画蒙太奇可用占位(简单 Character 动画或直接用计时器替代,系统已支持 Timer 模式)。UI Widget 使用简单 UMG 实现。交互物占位模型见场景执行案。 +> v0.1 原型:动画蒙太奇可用占位或 Timer 替代。UI Widget 使用简单 UMG。 --- @@ -937,79 +819,87 @@ void UInteractionManager::EnterAnimLockPhase() | 风险 | 等级 | 说明 | 缓解措施 | |------|------|------|---------| -| SetViewTargetWithBlend 回位时机 | 🟡中 | Blend 开始后需要在准确时间点切换回 Free 状态,过早会导致相机跳切 | Exit 阶段设置与 CameraReturnDuration 等长的定时器,定时器到后再切 State=Free | -| AnimMontage 缺失时兼容性 | 🟡中 | v0.1 可能没有完整动画资产,系统需在无蒙太奇时仍可运行 | 支持 Timer 模拟动画:AnimDuration 配置时长,计时器替代蒙太奇回调 | -| 多交互物重叠时排序抖动 | 🟢低 | 距离接近时,角色微小移动可能导致选中物来回切换 | 使用 DistSquared2D 比较(避免 sqrt),v0.1 数量少暂不加滞后阈值 | -| SphereComponent Overlap 性能 | 🟢低 | 10-20 个交互物同时检测,Tick 中排序 | v0.1 数量极少无性能压力;排序仅在列表变化时触发(Register/Unregister 时重排) | -| 角色转向期间 Tick 精度 | 🟢低 | 低帧率时转向插值可能不精确 | 使用 RInterpTo 基于 DeltaTime,帧率无关 | +| 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 硬控 | v0.1 用 TotalDuration 硬控;后续版本给 ShowNarrative 加 bWaitForInput 标记,Effect 暂停 Timeline 等待 | +| AInfo 驱动器生命周期 | 🟢低 | 关卡加载/卸载时需正确 Spawn/销毁 | OnWorldReady 中 Spawn,关卡卸载时 World 销毁自动清理 | ### 注意事项 -1. **Tick 开关优化**:State == Free 且 NearbyInteractables 为空时,关闭 TickComponent(`SetComponentTickEnabled(false)`)。注册首个交互物时开启。 -2. **推镜摄像机生命周期**:APushCameraActor 在 InteractionManager::BeginPlay 中 Spawn,随 Character 生命周期,不反复创建/销毁。 -3. **委托通知时机**:OnItemPickedUp / OnItemFound 在 ExecuteAnimEvent() 中广播,此时动画尚未结束。物品系统和收集系统需处理"动画未结束但已通知"的情况(v0.1 物品系统尚未实现,此通知仅作为架构预留)。 -4. **门状态持久化**:门状态通过 `UChildHoodGameInstance` 持久化。每次开关门时写入 `GI->SetDoorOpen()`,`InteractableComponent::RestorePersistentState()` 在 BeginPlay 时读取恢复。v0.1 单场景下等效于首次加载时所有门为关闭状态。 -5. **NPC 对话简化**:v0.1 Talk 类型在架构上与 Examine 共用 Active 阶段逻辑,仅文本来源不同。后续版本加入对话树时需扩展 Active 阶段。 +1. **Manager 不持有 World 引用**:通过 `GetGameInstance()->GetWorld()` 获取,注意 World 可能为空(加载中) +2. **Driver 是 Manager 容器**:`ASystemDriver` 持有 `FInteractionManager` 和 `FEffectManager`(普通 C++),显式控制创建顺序和 Tick 顺序,不依赖 UE Subsystem 生命周期 +3. **Manager 是普通 C++**:`FInteractionManager` / `FEffectManager` 不是 UObject,不参与 GC。所有 UObject 引用使用 `TWeakObjectPtr`,回调使用 `TFunction` +4. **InteractionManager 无 Tick**:所有操作由碰撞事件、输入回调、EffectManager 完成回调驱动,无每帧逻辑 +5. **GameInstance 不持有交互系统**:只有 `UPersistenceSubsystem` 跨 World 存活,所有交互逻辑随 World 生灭 +4. **Prompt 组件独立**:不参与 Effect 执行,只负责 Free 状态下的 UI 追踪 --- ## 9. 数据流 -### 配置参数流 +### Effect 配置流 ``` -AInteractableActor (编辑器实例) - → UInteractableComponent::Data (FInteractableData) - → InteractionManager 运行时读取 Data 中的类型/推镜/动画配置 +编辑器中配置 FInteractableData (per-instance) + → FInteractEffect: Action 列表 + 编排参数 + → 关卡运行时,Manager 读 Data.InteractEffect + → EffectManager.PlayEffect(Effect, Context) 执行 ``` -### 运行时状态流 +### 运行时数据流 ``` 输入 (Enhanced Input) - → PlayerController 回调 - → InteractionManager::BeginInteract / CancelInteract / SwitchTarget - → 状态机驱动 (State / Phase) - → 角色转向 (Tick 插值) - → 摄像机推镜/回位 (SetViewTargetWithBlend) - → 动画播放 (Montage_Play 或 Timer) - → UI 显隐 (Widget 动画) - → 物品系统通知 (委托广播) + → Character 输入回调 + → ASystemDriver::Get(World)->InteractionManager.BeginInteract() // 返回选中 Component + → ASystemDriver::Get(World)->EffectManager.PlayEffect(Effect, Context) // 执行 Effect + → Timeline 驱动 Action 按 TriggerTime 触发 + → 各 Action 直接操作 UE API / 调用系统接口 + +Effect 完成(Driver 中 std::function 连线) + → EffectManager.OnEffectComplete() + → InteractionManager.OnInteractionFinished() → State = Free + → Prompt 组件检测到 Free → 恢复 Prompt ``` -### 检测注册流 +### Prompt 数据流(独立于 Effect,只读 Manager 缓存) ``` -角色移动 → 进入 SphereComponent 范围 - → InteractableComponent::OnDetectionOverlapBegin - → InteractionManager::RegisterInteractable - → NearbyInteractables 排序 - → 选中更新 - → 提示 UI 显示/切换 +PromptComponent::TickComponent + → Manager->GetState() → Free? + → Manager->HasSelection() → 有选中? + → Manager->GetSelectedPromptText() → 缓存的文本 + → Manager->GetSelectedWorldPosition() → 缓存的世界坐标 + → ProjectWorldToScreen → 屏幕坐标 + → Widget->SetPromptText + SetScreenPosition +``` + +``` +Manager 缓存刷新(选择变化时触发): + RegisterInteractable / UnregisterInteractable / SwitchTarget + → RefreshCachedSelection() + → 从 Component 读取 PromptText + WorldPosition + → 写入 CachedSelected ``` --- ## 10. 可调参数汇总 -| 参数 | C++ 属性 | 默认值 | 位置 | -|------|---------|--------|------| -| 触发半径 | `FInteractableData::TriggerRadius` | 150.0 cm | InteractableComponent 编辑器 | -| 提示 UI 淡入时长 | `PromptFadeInDuration` | 0.2s | InteractionManager 构造函数 | -| 提示 UI 淡出时长 | `PromptFadeOutDuration` | 0.2s | InteractionManager 构造函数 | -| 角色转向时长 | `TurnDuration` | 0.2s | InteractionManager 构造函数 | -| 推镜距离 | `FInteractableData::CameraPushDistance` | 80-100 cm | InteractableComponent 编辑器 | -| 推镜时长 | `FInteractableData::CameraPushDuration` | 0.5s | InteractableComponent 编辑器 | -| 推镜后等待 | `FInteractableData::PushWaitDelay` | 0.2s | InteractableComponent 编辑器 | -| 相机回位时长 | `FInteractableData::CameraReturnDuration` | 0.3s | InteractableComponent 编辑器 | -| 文本底框背景透明度 | `TextBgOpacity` | 0.6 | WBP_NarrativeText 蓝图 | -| 文本底框淡入时长 | `TextFadeInDuration` | 0.2s | WBP_NarrativeText 蓝图 | -| 文本底框淡出时长 | `TextFadeOutDuration` | 0.2s | WBP_NarrativeText 蓝图 | -| 拾取动画时长 | `FInteractableData::AnimDuration` | 1.0s | InteractableComponent 编辑器 | -| 物品消失时间点 | `FInteractableData::AnimEventTimepoint` | 0.6 | InteractableComponent 编辑器 | -| 门操作动画时长 | `FInteractableData::AnimDuration` | 0.8s | InteractableComponent 编辑器 | -| 门状态切换时间点 | `FInteractableData::AnimEventTimepoint` | 0.4 | InteractableComponent 编辑器 | -| 翻找动画时长 | `FInteractableData::AnimDuration` | 1.5s | InteractableComponent 编辑器 | +| 参数 | 位置 | 默认值 | +|------|------|--------| +| 触发半径 | `FInteractableData::TriggerRadius` | 150 cm | +| Effect TotalDuration | `FInteractEffect::TotalDuration` | 2.0s | +| Effect bCancellable | `FInteractEffect::bCancellable` | true | +| Action TriggerTime | `FInteractAction::TriggerTime` | 0s | +| 转向时长 | `FInteractAction::TurnDuration` | 0.2s | +| 推镜距离 | `FInteractAction::PushDistance` | 80 cm | +| 推镜时长 | `FInteractAction::PushDuration` | 0.5s | +| 回位时长 | `FInteractAction::ReturnDuration` | 0.3s | +| Prompt 高度偏移 | `UInteractionPromptComponent::PromptHeightOffset` | 80 cm | + +> 所有时间/距离参数均在 per-instance 的 Effect 配置中可调,不再硬编码。 --- @@ -1018,148 +908,119 @@ AInteractableActor (编辑器实例) ``` Source/ChildHood/ ├── Interaction/ -│ ├── InteractionTypes.h # 枚举、结构体、委托定义 -│ ├── InteractionManager.h/.cpp # 交互状态机核心组件(含 GameInstance 写入调用) -│ ├── InteractableComponent.h/.cpp # 可交互物组件(数据+检测+持久状态恢复) -│ ├── InteractableActor.h/.cpp # 通用交互物 Actor 基类(BeginPlay 调用恢复) -│ └── PushCameraActor.h/.cpp # 推镜用临时摄像机 -├── ChildHoodGameInstance.h/.cpp # 新增:跨关卡持久数据持有者 -├── ChildHoodCharacter.h/.cpp # 修改:添加交互支持 -└── ChildHoodPlayerController.h/.cpp # 修改:添加交互输入绑定 +│ ├── InteractionTypes.h # 枚举、结构体(FInteractAction/FInteractEffect 等) +│ ├── InteractionManager.h/.cpp # FInteractionManager(普通C++,检测/选择/状态) +│ ├── EffectManager.h/.cpp # FEffectManager(普通C++,Effect调度/Action执行) +│ ├── PersistenceSubsystem.h/.cpp # UPersistenceSubsystem(跨场景持久化) +│ ├── InteractableActor.h/.cpp # 通用交互物基类(干净,无门逻辑) +│ ├── InteractableComponent.h/.cpp # 通用组件(数据+检测,无门逻辑) +│ ├── DoorActor.h/.cpp # 门 Actor(Mesh 旋转) +│ ├── DoorInteractableComponent.h/.cpp # 门组件(开关状态+动词切换) +│ ├── InteractionPromptComponent.h/.cpp # Prompt UI 组件 +│ ├── InteractionPromptWidget.h/.cpp # Prompt Widget 基类 +│ ├── NarrativeTextWidget.h/.cpp # 文本 Widget 基类 +│ └── PushCameraActor.h/.cpp # 推镜相机 Actor +├── SystemDriver.h/.cpp # ASystemDriver(AInfo,持有所有World级Manager) +├── ChildHoodGameInstance.h/.cpp # 干净壳,不持有交互对象 +├── ChildHoodCharacter.h/.cpp # 持有 PromptComponent,通过 Driver 访问 Manager +└── ChildHoodPlayerController.h/.cpp # 输入绑定 Content/ ├── Blueprints/ -│ └── BP_Interactable.uasset # 可配置交互物蓝图(基于 AInteractableActor) +│ └── BP_DoorFrame.uasset # 门蓝图(基于 ADoorActor) ├── UI/ -│ ├── WBP_InteractionPrompt.uasset # 交互提示 UI -│ └── WBP_NarrativeText.uasset # 文本底框 UI +│ ├── WBP_InteractionPrompt.uasset # 交互提示 +│ ├── WBP_NarrativeText.uasset # 文本底框 +│ └── WBP_PickupNotification.uasset # 拾取通知 ├── Input/ -│ ├── IA_Interact.uasset # E 键交互 -│ ├── IA_Cancel.uasset # ESC 取消 -│ └── IA_SwitchInteract.uasset # Tab/滚轮切换 +│ ├── IA_Interact.uasset +│ ├── IA_Cancel.uasset +│ └── IA_SwitchInteract.uasset └── Animations/ - ├── AM_Pickup.uasset # 弯腰拾取蒙太奇(v0.1 可选) - ├── AM_PushDoor.uasset # 推门蒙太奇(v0.1 可选) - └── AM_Search.uasset # 翻找蒙太奇(v0.1 可选) + ├── AM_Pickup.uasset + ├── AM_PushDoor.uasset + └── AM_Search.uasset ``` --- ## 12. 跨场景持久化设计 -> UE5 的 `OpenLevel()`(非无缝跳转)会销毁整个旧 World,包括 Character 及其所有 UActorComponent。本节定义交互系统如何通过 `UChildHoodGameInstance` 实现跨场景数据持久化。 - -### 架构原则 +### 架构 ``` ┌──────────────────────────────────────────┐ -│ UChildHoodGameInstance │ ← 跨关卡存活 -│ ├── CollectedItemIDs: TSet │ 交互物消耗状态 -│ └── DoorStates: TMap │ 门开关状态 +│ UPersistenceSubsystem │ ← GameInstanceSubsystem,跨关卡存活 +│ ├── CollectedItemIDs: TSet │ +│ └── DoorStates: TMap │ └──────────────────────────────────────────┘ ↕ 读取/写入 ┌──────────────────────────────────────────┐ -│ 场景中的 AInteractableActor │ ← 随场景加载/销毁 -│ └── BeginPlay → RestorePersistentState │ 查询并恢复状态 +│ UInteractableComponent::BeginPlay │ ← 随场景加载/销毁 +│ └── RestorePersistentState(Subsystem) │ 查询并恢复状态 └──────────────────────────────────────────┘ ``` -### UChildHoodGameInstance 接口 +### UPersistenceSubsystem 接口 ```cpp UCLASS() -class UChildHoodGameInstance : public UGameInstance +class UPersistenceSubsystem : public UGameInstanceSubsystem { GENERATED_BODY() - public: - // === 物品消耗状态 === bool IsItemCollected(FName ItemId) const; void MarkItemCollected(FName ItemId); - - // === 门状态 === bool IsDoorOpen(FName DoorId) const; void SetDoorOpen(FName DoorId, bool bIsOpen); - - // === 重置(新游戏时调用)=== void ResetAll(); - private: - TSet CollectedItemIDs; - TMap DoorStates; + UPROPERTY() TSet CollectedItemIDs; + UPROPERTY() TMap DoorStates; }; ``` -### 恢复流程 - -`AInteractableActor::BeginPlay()` 或 `UInteractableComponent::BeginPlay()` 中: - -```cpp -void UInteractableComponent::RestorePersistentState(UChildHoodGameInstance* GI) -{ - if (!GI) return; - - // oneShot 物品:已消耗 → 隐藏 + 禁用检测 - if (Data.bOneShot && GI->IsItemCollected(Data.ItemId)) - { - bIsConsumed = true; - if (USphereComponent* Sphere = DetectionSphere) - Sphere->SetCollisionEnabled(ECollisionEnabled::NoCollision); - if (AInteractableActor* Owner = Cast(GetOwner())) - Owner->SetActorHiddenInGame(true); - } - - // 门:恢复开关状态 - if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door) - { - bDoorIsOpen = GI->IsDoorOpen(Data.ItemId); - // 通知 Actor 应用视觉状态 - if (AInteractableActor* Owner = Cast(GetOwner())) - Owner->OnInteractStarted(this); // 复用已有的 Mesh 旋转逻辑 - } -} -``` - ### 写入时机 -| 事件 | 调用位置 | 写入方法 | -|------|---------|---------| -| oneShot 物品被消耗 | `UInteractableComponent::Consume()` | `GI->MarkItemCollected(ItemId)` | -| 门开关状态变化 | `InteractionManager::ExecuteAnimEvent()` Door 分支 | `GI->SetDoorOpen(ItemId, bIsOpen)` | +| 事件 | 触发 Action | 写入方法 | +|------|------------|---------| +| 物品消耗 | `Consume` Action | `Subsystem->MarkItemCollected(ItemId)` | +| 门开关切换 | `ToggleDoor` Action | `Subsystem->SetDoorOpen(DoorId, bIsOpen)` | -### v0.1 行为 +### 恢复流程 -- v0.1 为单场景原型,不存在跨场景切换 -- `UChildHoodGameInstance` 在 v0.1 中以空数据初始化,所有 `IsItemCollected()` 返回 false -- 后续多场景版本只需在 Project Settings 中将 GameInstance Class 设为 `UChildHoodGameInstance`,无需修改交互系统代码 +`UInteractableComponent::RestorePersistentState`(基类):恢复物品消耗状态。 +`UDoorInteractableComponent::RestorePersistentState`(派生类):额外恢复门开关状态 + 通知 DoorActor 视觉旋转。 + +恢复调用链:`Component::BeginPlay → GetGameInstance()->GetSubsystem() → RestorePersistentState()` --- -## 工期预估与实现拆分 +## 13. 工期预估与实现拆分 ### 工作量估算 | 维度 | 估算 | |------|------| -| 总工期 | 5-7 天 | -| 新增 C++ 代码 | 600-800 行 | -| 新增蓝图 | 3 个 UMG + 1 个 Actor BP | -| 修改现有代码 | 约 50 行 | -| 实现复杂度 | 中-高 | +| 总工期 | 6-8 天 | +| 新增 C++ 代码 | 800-1000 行 | +| 新增蓝图 | 3 个 UMG + 门 BP | +| 修改现有代码 | 约 100 行 | ### 实现拆分建议 -供步骤⑥(程序实现)参考的推荐拆分方式,每个子任务可独立验证: +| 子任务 | 内容 | 预估 | 依赖 | 验证方式 | +|--------|------|------|------|---------| +| 13a | InteractionTypes 重写 + FInteractAction/FInteractEffect 结构体 | 0.5d | 无 | 编译通过 | +| 13b | UPersistenceSubsystem + UChildHoodGameInstance 清理 | 0.5d | 13a | PIE 测试 Subsystem 读写 | +| 13c | InteractableComponent 清理(无门逻辑)+ InteractableActor 清理 | 0.5d | 13a | 编译通过 | +| 13d | DoorActor + DoorInteractableComponent | 0.5d | 13c | PIE 门旋转 + 状态持久化 | +| 13e | ASystemDriver + FInteractionManager | 1d | 13b | PIE Driver 创建 Manager,检测日志验证 | +| 13f | FEffectManager + Driver Tick | 0.5d | 13e | PIE EffectManager 通过 Driver Tick 验证 | +| 13g | UEffectManager + Action 执行分发 | 1.5d | 13a,13e | PIE 拾取 Effect 完整执行 | +| 13h | UInteractionPromptComponent + Character 集成 | 1d | 13e | PIE Prompt 显示/隐藏 | +| 13i | 全部 Effect 配置 + 10 交互物场景验证 | 1d | 13g,13h | 跑完 v0.1 探索路线 | +| 13j | 蓝图迁移 + 集成测试 | 0.5d | 13i | 全流程回归 | -| 子任务 | 内容 | 预估时间 | 依赖 | 验证方式 | -|--------|------|---------|------|---------| -| 6a. 数据类型定义 | InteractionTypes.h 枚举/结构体/委托 + InteractableComponent 基础结构 | 0.5 天 | 无 | 编译通过 | -| 6b. InteractableComponent | USphereComponent 检测 + FInteractableData + Register/Unregister 逻辑 + AInteractableActor + BP_Interactable | 1 天 | 6a | PIE 中放置交互物,角色走近时控制台输出检测日志 | -| 6c. InteractionManager 核心 | 状态机 + 检测列表排序 + 选中管理 + Character 集成(bIsInteracting) + InputAction 绑定 | 1.5 天 | 6a, 6b | PIE 中走近交互物,日志显示选中切换 | -| 6d. 提示 UI | WBP_InteractionPrompt + 屏幕投影定位 + 淡入淡出 | 0.5 天 | 6c | PIE 中走近交互物看到提示,走近另一个时切换 | -| 6e. Enter 阶段(转向 + 推镜) | TickEnterPhase + APushCameraActor + SetViewTargetWithBlend | 1 天 | 6c | PIE 按 E 后角色转向 + 相机推近 | -| 6f. 类型处理器 + AnimLock | 四种类型的完整流程 + Timer/Montage 动画 + 动画事件 | 1.5 天 | 6e | PIE 验证四种交互类型各自完整流程 | -| 6g. 文本 UI + Active/Exit | WBP_NarrativeText + ShowText/HideText + 相机回位 | 0.5 天 | 6f | PIE 观察类完整流程:推镜→文本→按E→回位 | -| 6h. 跨系统委托 + 集成测试 | OnItemPickedUp/OnItemFound 委托 + 10 个交互物场景验证 | 0.5 天 | 6g | 按探索路线跑完全部交互物 | - -> **关键路径**:6a → 6b → 6c → 6e → 6f → 6g → 6h。6d(提示 UI)可在 6c 完成后并行。总关键路径约 5.5 天。 +> **关键路径**:13a → 13b → 13e → 13f → 13g → 13i → 13j。总关键路径约 5.5 天。 +> 13c/13d(门派生类)和 13h(Prompt)可在 13e 完成后并行。 diff --git a/docs/technical/交互系统-拾取时序图.md b/docs/technical/交互系统-拾取时序图.md new file mode 100644 index 0000000..8b64ccd --- /dev/null +++ b/docs/technical/交互系统-拾取时序图.md @@ -0,0 +1,224 @@ +# 交互系统 · 拾取流程时序图(v1.0 Effect 驱动版) + +## 场景 + +玩家进入地上某个交互物的交互范围,按 E 交互后,人物做动作,交互物消失,背包加一个新物品,UI 显示捡到一个物品。 + +## 架构层级 + +| 层级 | 实体 | 所在位置 | +|------|------|---------| +| **Logic** | `FInteractionManager` | ASystemDriver 持有 (普通C++, 纯事件驱动, 缓存 Presentation 数据) | +| **Logic** | `FEffectManager` | ASystemDriver 持有 (普通C++, Driver 提供Tick) | +| **World** | `ASystemDriver` | AInfo 全局唯一 (持有所有Manager, 显式控制创建/Tick顺序) | +| **Data** | `UInteractableComponent` | 关卡实例 | +| **Data** | `UPersistenceSubsystem` | GameInstanceSubsystem | +| **Data** | `BackpackSystem` | GameInstance | +| **Presentation** | `UInteractionPromptComponent` | Character | + +## 数据流规则 + +- 表现层直接读数据层(Manager state / Component data / Backpack data) +- 逻辑事件仅在 A 影响 B 时使用(如 Consume Action → Persistence 写入) +- 两个表现实体之间不直接访问 + +--- + +## Effect 配置 + +``` +Effect: "拾取地面物品" +TotalDuration: 1.5s, Cancellable: false + +TriggerTime | Action | 说明 +----------- | --------------------- | ---- +0.0s | TurnToTarget | TurnDuration=0.2,转向交互物 +0.0s | PlayMontage | Montage=AM_Pickup,弯腰拾取动画 +0.6s | Consume | 标记消耗,禁用检测球 +0.6s | HideActor | 隐藏交互物 Actor +0.6s | AddToBackpack | 物品进背包 + 持久化 +1.0s | ShowPickupNotification| 显示 "捡到了 [旧橡皮]!" +``` + +--- + +## 时序图 + +```mermaid +sequenceDiagram + autonumber + + actor Player as 玩家 + participant Char as AChildHoodCharacter + participant Mgr as FInteractionManager
(普通C++ · ASystemDriver持有) + participant Comp as UInteractableComponent
(Data · 关卡实例) + participant Actor as AInteractableActor + participant EFM as FEffectManager
(普通C++ · ASystemDriver持有) + participant Persist as UPersistenceSubsystem
(Data · GameInstance) + participant Bag as BackpackSystem + participant Pres as UInteractionPromptComponent
(Presentation · Character) + participant Driver as ASystemDriver
(AInfo · 持有所有Manager) + + rect rgb(230, 245, 255) + Note over Player,Driver: ═══ Phase 0 · 进入检测范围 ═══ + Player->>Char: WASD 移动进入 TriggerRadius + Comp->>Mgr: DetectionSphere OnOverlapBegin
ASystemDriver::Get(World)->GetInteractionManager().RegisterInteractable(this) + Mgr->>Mgr: NearbyInteractables.Add(Comp)
Sort by Dist2D → SelectedIndex = 0 + Mgr->>Mgr: RefreshCachedSelection()
缓存 PromptText + WorldPosition + end + + rect rgb(255, 250, 230) + Note over Player,Driver: ═══ Phase 1 · Prompt 显示(Pres 每帧读 Manager 缓存)═══ + loop 每帧 Pres TickComponent + Pres->>Mgr: GetState() → Free + Pres->>Mgr: HasSelection() → true + Pres->>Mgr: GetSelectedPromptText() → "[E] 拾取 旧橡皮" + Pres->>Mgr: GetSelectedWorldPosition() → FVector + Pres->>Pres: PromptWidget.SetText + SetScreenPosition + end + Note right of Pres: Pres 不碰 Component
只读 Manager 缓存 + end + + rect rgb(230, 255, 230) + Note over Player,Driver: ═══ Phase 2 · 按 E,Character 通过 Driver 协调两个 Manager ═══ + Player->>Char: 按 E (IA_Interact) + Char->>Mgr: Driver->GetInteractionManager().BeginInteract() → 返回 Comp + Mgr->>Mgr: State = Executing, CurrentInteractable = Comp + Char->>Char: SetInteracting(true) → 屏蔽移动 + + Char->>EFM: Driver->GetEffectManager().PlayEffect(Comp.Data.InteractEffect, Context)
Context = {Char, Comp, Actor} + Note right of EFM: Effect "拾取地面物品"
T=0.0 TurnToTarget + PlayMontage
T=0.6 Consume+Hide+Backpack
T=1.0 PickupNotification
Total=1.5s + end + + rect rgb(255, 240, 245) + Note over Player,Driver: ═══ Phase 3 · Presentation 检测状态变化 ═══ + Pres->>Mgr: Tick: GetState() → Executing (变化!) + Pres->>Pres: PromptWidget.Hide() + end + + rect rgb(240, 240, 255) + Note over Player,Driver: ═══ Phase 4 · Effect 执行(ASystemDriver Tick → EffectManager)═══ + + Note over Driver: T=0.0: TurnToTarget + PlayMontage + Driver->>EFM: Driver::Tick → EffectManager.Update(DeltaTime) + EFM->>EFM: ElapsedTime=0.0 → 触发 TurnToTarget Action + EFM->>Char: RInterpTo 转向 Comp(持续帧) + EFM->>EFM: 触发 PlayMontage Action + EFM->>Char: GetMesh()->GetAnimInstance()->Montage_Play(AM_Pickup) + Note right of Char: 播放弯腰拾取动画 + + Note over Driver: T=0.0→0.6: 持续转向 + 动画播放 + loop 每帧 Driver Tick + Driver->>EFM: Tick → Update(DeltaTime) + EFM->>EFM: ElapsedTime += DeltaTime + EFM->>Char: TurnToTarget: RInterpTo 插值 + end + + Note over Driver: T=0.6: Consume + HideActor + AddToBackpack + Driver->>EFM: Tick → Update → ElapsedTime=0.6 触发 + EFM->>Comp: Consume() — 数据变更 + Comp-->>Comp: bIsConsumed=true
DetectionSphere → NoCollision + Note right of Comp: 禁用碰撞同步触发 EndOverlap
→ UnregisterInteractable
Manager 标记延迟清理 + + EFM->>Persist: MarkItemCollected("rubber_01") + Persist-->>Persist: CollectedItemIDs.Add("rubber_01") + + EFM->>Actor: SetActorHiddenInGame(true) + + EFM->>Bag: AddItem("rubber_01", "旧橡皮") + Bag-->>Bag: Items.Add({Id:"rubber_01", Name:"旧橡皮"}) + + Pres->>Comp: Tick: IsConsumed() → true (变化!) + Pres->>Actor: SetActorHiddenInGame(true) + + Note over Driver: T=1.0: ShowPickupNotification + Driver->>EFM: Tick → Update → ElapsedTime=1.0 触发 + EFM->>Pres: 显示拾取通知 "捡到了 [旧橡皮]!" + end + + rect rgb(230, 255, 255) + Note over Player,Driver: ═══ Phase 5 · Effect 完成,回到 Free ═══ + Driver->>EFM: Tick → Update → ElapsedTime >= TotalDuration(1.5s) + EFM->>EFM: FinishEffect() + EFM->>Mgr: OnEffectComplete 回调 + Mgr->>Mgr: State = Free, CurrentInteractable = nullptr + Mgr->>Char: SetInteracting(false) → 恢复移动 + + Pres->>Mgr: Tick: GetState() → Free (变化!) + Pres->>Mgr: GetSelectedInteractable() → null (已注销) + Note over Pres: 无其他交互物在范围,不显示 Prompt + + Player->>Char: WASD 继续移动 + end +``` + +--- + +## 关键数据流说明 + +### 表现层读数据(只读 Manager 缓存,不碰 Component) + +| 读取时机 | PromptComponent 读谁 | 读什么 | +|----------|---------------------|--------| +| 每帧 Tick | Manager | `GetState()` | +| Free 状态 | Manager | `HasSelection()` | +| Free 状态 | Manager | `GetSelectedPromptText()` | +| Free 状态 | Manager | `GetSelectedWorldPosition()` | + +### Manager 缓存更新(选择变化时从 Component 读一次) + +| 触发时机 | Manager 方法 | 缓存内容 | +|----------|-------------|---------| +| RegisterInteractable | RefreshCachedSelection() | PromptText + WorldPosition | +| UnregisterInteractable | RefreshCachedSelection() | PromptText + WorldPosition | +| SwitchTarget | RefreshCachedSelection() | PromptText + WorldPosition | + +### Effect 内 Action 执行(直接操作) + +| Action | 操作对象 | 说明 | +|--------|---------|------| +| TurnToTarget | Char->SetActorRotation | RInterpTo 跨帧插值 | +| PlayMontage | Char->GetMesh()->GetAnimInstance() | 播放蒙太奇 | +| Consume | Component->Consume() | 标记消耗+禁用检测 | +| HideActor | Actor->SetActorHiddenInGame(true) | 隐藏 | +| AddToBackpack | PersistenceSubsystem + BackpackSystem | 写入持久层+背包 | +| ShowPickupNotification | PromptComponent / Widget | 显示通知 | + +### 逻辑事件(仅 A 影响 B) + +| 事件 | 触发者 | 订阅者 | +|------|--------|--------| +| OnItemConsumed | Manager(由 Consume Action 触发) | PersistenceSubsystem | +| OnDoorToggled | Manager(由 ToggleDoor Action 触发) | PersistenceSubsystem + DoorComponent | + +--- + +## 注意事项 + +### EndOverlap 时序问题 + +Phase 4 中 `Consume()` 禁用 DetectionSphere 会**同步触发** `EndOverlap`,进而调用 `Manager->UnregisterInteractable()`。此时 Manager 处于 Executing 状态且 Effect 仍在使用 CurrentInteractable。 + +**解决方案**:Manager 的 `UnregisterInteractable` 中,当 `State == Executing` 且移除的正是 `CurrentInteractable` 时,仅从 NearbyInteractables 列表移除,**不清除 CurrentInteractable 引用**,等 Effect 完成回调中统一清理。 + +### ASystemDriver 生命周期 + +- `ASystemDriver` 是 AInfo 全局唯一,持有所有 World 级 Manager(普通 C++ 对象) +- 关卡加载完成 → `GameInstance` Spawn `ASystemDriver` +- `Driver::BeginPlay` 显式创建 `FInteractionManager` + `FEffectManager`,控制顺序 +- `Driver::BeginPlay` 中用 `TFunction` 连线 `EffectManager.OnEffectComplete → Manager.OnInteractionFinished` +- `Driver::Tick` 显式调用 `EffectManager.Update(Delta)`,控制 Tick 顺序 +- 关卡卸载 → World 销毁 → Driver 自动销毁 → Manager 随 Driver 析构 +- 新关卡加载 → 重新 Spawn + 创建 + +### 系统协调关系 + +- `FInteractionManager` 和 `FEffectManager` 是普通 C++ 类,由 `ASystemDriver` 持有 +- `AChildHoodCharacter` 输入回调通过 `ASystemDriver::Get(World)` 访问两个 Manager +- `Driver::BeginPlay` 中通过 `TFunction` 连接 EffectManager 完成回调到 Manager +- `FInteractionManager` 纯事件驱动,无 Tick/Update +- `UChildHoodGameInstance` 不持有任何交互系统对象,仅 `UPersistenceSubsystem` 跨 World 存活 + +### TurnToTarget 跨帧插值 + +TurnToTarget Action 不是瞬时完成的。EffectManager 在 Update 中维护 `FTurnState`(起始旋转、目标旋转、已用时间),每帧调用 `FMath::RInterpTo` 直到转向完成。多个 Action 可以并行执行。