Compare commits

..

1 Commits

401 changed files with 3690 additions and 11822 deletions
@@ -0,0 +1,5 @@
# 策划 Agent Memory Index
- [千禧年怀旧文化灵感](nostalgia-culture-inspirations.md) — 电视剧/广播体操/广场舞/电话热线/零食/小孩游戏/乡下活动灵感汇总
- [建筑与区域规划](building-area-plan.md) — 城市主场景完整建筑清单、可进入室内、新增设施共识
- [v0.1执行案状态](v01-execution-status.md) — 三份执行案已通过二次质量门审查,流水线可推进至④程序设计
@@ -0,0 +1,37 @@
---
name: building-area-plan
description: 城市主场景建筑与区域规划共识,包含完整建筑清单、可进入室内、新增设施
metadata:
type: project
---
## 城市主场景建筑清单(2026-06-10 确认)
来源:头脑风暴-2026-06-10-建筑与区域规划.md
### 关键变更(vs 原场景设计)
- 小伙伴家:原"全部不可进入" → 4家可进入(简化室内)
- 包机房:合并进街机店后屋
- 废品回收:固定摊位 + 流动收废品车
- 秘密基地:多处散布(工地围挡后、平房区死角等)
- 新增店铺:粮油店、服装店/裁缝铺、鞋店(主街东侧)、储蓄所(西侧)
- 新增设施:棋牌室(纯氛围)、公交站牌(纯装饰)、公共厕所、自行车棚、信报箱
### 主街布局(东西排列)
东→:学校操场 | 小卖部 | 粮油店 | 书店 | 服装店 | 鞋店 · 公交站 · 棋牌室 | 储蓄所 | 录像出租 | 网吧 | 街机店 ←西
### 可进入室内(~12个)
完整:主角家、合并小卖部、书店、街机店、网吧、录像出租点
简化:死党A家(小区内)、死党B家(平房区)、老板儿子家(小卖部后屋)、女班长家(主角家同楼)
### 不做的元素
菜地(留乡下)、装修、游泳(河边不下水)
### How to apply:
- 场景与世界观.md 已更新为最新清单
- 新增店铺和设施在后续版本逐步添加(见版本映射)
- 概念草案不变(冻结),变更记录在系统设计案中
@@ -0,0 +1,87 @@
---
name: nostalgia-culture-inspirations
description: 千禧年怀旧文化元素灵感 + 迈皋桥地区历史地理考据,含共识和待深入项
metadata:
type: project
---
## 头脑风暴记录
来源:brainstorm-2026-06-05-nostalgia-culture.md
日期:2026-06-05/06
状态:方向确认完毕,待后续决定具体用法
### ⭐ 电话热线玩电视游戏 — 最值得深入
- 2000年前后电视台互动节目:打电话用按键音控制屏幕上的射击游戏
- 作为游戏内小游戏的潜力最大:小游戏+叙事+时代感三合一
- 体验流程:走到电话旁 → 拨号 → 进入简化版射击游戏 → 打完显示话费 → 「完了,肯定要被我妈骂」
- starmen1265 明确标记为「值得深入」
### 电视剧/动画文化
- 千禧年小孩记忆锚点:还珠格格、西游记、灌篮高手、龙珠、迪迦奥特曼、数码宝贝
- Cartoon Network 通过有线电视收看,是「有有线才能看的频道」
- 引进外国剧:排球女将、成长的烦恼等
- 城郊特有:CRT电视、天线收台、拍电视机、有线电视刚铺开
### 广播体操
- 第八套广播体操(1997)或「雏鹰起飞」(少儿版)
- 潜力方向:氛围事件、节奏小游戏、叙事道具
- 城郊特色:泥土操场、大喇叭杂音、冬天缩手做
### 广场舞/社区活动
- 2000年前后是前身形态(十几到几十人、收录机放磁带)
- 城郊特色:扭秧歌、露天电影、露天卡拉OK
### 迈皋桥地区历史地理考据(关键发现)
**2000年的迈皋桥处于"正在城市化"的过渡状态**:
- 迈皋桥广场不存在(约2005年随地铁建成,原址是石牌坊+屠宰场)
- 红山路已存在(90年代建成)
- 老街1998年启动拆迁,新旧交替
- 734厂已拆,741厂(华电)还在转型
- 大片菜地+农村平房正在被小区取代
- 南北十里长沟是臭水沟("龙须沟"),2013年才整治
- 两座7000+职工的国营大厂曾主导整个社区
- 小卖部、大市场、高桥市场等商业氛围浓厚
### How to apply:
- 电话热线游戏适合进入小游戏系统设计,需确定版本归属
- 电视剧/动画元素适合做世界氛围素材(电视内容、贴纸、NPC对话)
- 广播体操和广场舞适合作为场景氛围事件
- 迈皋桥考据直接用于场景设计的"半城半乡"氛围塑造
- 华电子弟学校、工厂宿舍区、菜地、老街都是高还原度的场景元素
---
## 2026-06-10 灵感补充
来源:头脑风暴-2026-06-10-建筑与区域规划.md
### 重点新发现(⭐⭐⭐)
1. **钻狗洞的秘密基地** — 多处散布(工地围挡后、平房区死角),高探索叙事价值
2. **叠纸飞机** — 纸张收集+折叠选择+飞行结果三层玩法
3. **不知道名字的小哥哥** — 一次性邂逅NPC,极高情感潜力微型叙事线
4. **体力系统+家庭动态事件** — 吃饭补充体力→不吃炒饭→换速冻水饺→给饭钱
5. **爷爷奶奶家亲戚小孩** — 乡下NPC角色体系(之前乡下NPC完全空白)
### 新零食/小吃(具体品牌品种)
大大泡泡糖、秀逗(超酸糖)、无花果丝、老鼠屎(盐津葡萄)、跳跳糖、烤鱿鱼、烤里脊
### 新小孩游戏
双人拍手/互推游戏、猜在哪个手里、跑步比赛、叠纸飞机
### 新人物/社交
- 北方来南方的同学(与"转学生"角色互补)
- 等着去别人家(串门文化)
### 新乡下活动
钓鱼(鱼竿)、游泳、挤牛奶、喂鸡掏鸡蛋、菜地浇水、打水漂
@@ -0,0 +1,26 @@
---
name: v01-execution-status
description: v0.1三份执行案的质量门审查状态——二次审查已通过,但Gitea Issues标签尚未同步
metadata:
type: project
---
## v0.1 执行案审查状态(截至2026-06-05)
三份执行案在二次审查(P-002-review-r2.md)中全部 **✅ 通过**:
- `interaction-system-execution.md` (v0.1.2) — 优秀
- `collection-system-execution.md` (v0.1.1) — 优秀
- `player-controller-execution.md` (v0.1.1) — 良好
### Why:
首次审查有5条阻塞问题(缺失独立文件、技术越界、内容匮乏、边界缺失),经过3轮迭代全部修正。
### 待同步事项:
- Gitea Issues #1/#2/#3 仍标记为 `gate:execution-fail`、`status:blocked`,需 PM 更新标签
- Pipeline Tracker (#8) 仍显示③为「质量门未通过」,需 PM 更新为通过
- `v01-scope.md` 中 "WASD 步行移动" 需更新为 "WASD 跑步移动"(策划待同步项)
### How to apply:
- 策划侧当前无阻塞任务,流水线可推进至④程序设计
- 非阻塞建议(数据类型描述改行为语言、拾取物外观改纯视觉语言)留待量产阶段
- 物品系统无独立执行案,④程序设计阶段由程序统一处理
+5
View File
@@ -0,0 +1,5 @@
- [Level Builder Tool](level-builder-tool.md) — 白模关卡自动生成 Python 脚本(tools/level-builder/,265→238 Actor 修复后验证通过)
- [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独立)
@@ -0,0 +1,34 @@
---
name: bsp-pivot-types
description: BlockingStarterPack 全部 38 种 BSP 资产的实测锚点位置和补偿方案
metadata:
type: project
---
# BSP 资产锚点实测数据(2026-06-08)
## 锚点模式分类
| 模式 | 资产 | 特征 | 补偿 |
|------|------|------|------|
| **角落** | Floor_*, Cube, Fence_Small_400 | min≈(0,0,0) | XY 各偏半尺寸 |
| **底边中心** | Wall_*, BarrierWall_* | min≈(0, ±half_thickness, 0) | X 偏半宽度,Y 已居中 |
| **底面中心** | Cylinder, CirclePlatform, 家具, 道具 | min≈(-half, -half, 0) | 无需补偿 |
## 关键实测尺寸(非 default_size)
- **BarrierWall_600**: 实际 600×**60**×**210**cm(非 mapping 中的 20×100)
- **BarrierWall_200**: 实际 200×**60**×**210**cm
- **Floor_1000x1000**: Z 范围 -20 到 2(厚度 22cm,表面在 Z=2)
- **Fence_Small_400**: 实际 407×6×112cm
- **Wall_400x300**: 所有墙体 Y 方向已居中(±10cm)
## 补偿实现
`asset_mapping.py` 中 `pivot_offset_xy` 字段 + `get_pivot_compensation()` 函数:
- 输入: asset_key, scale, rotation
- 输出: (dx, dy) 偏移量
- builder 中: spawn_pos = json_pos - compensation
## Why: 搭建指南统一用视觉中心坐标,但 BSP 资产锚点各异,不补偿会差半模型
## How to apply: 新增 BSP 资产时先跑 check_pivots.py 实测锚点,再填 pivot_offset_xy。新增搭建指南时位置直接写视觉中心即可。
@@ -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,只读缓存
@@ -0,0 +1,27 @@
---
name: level-builder-bugfix-v01
description: v01白模关卡8个问题的诊断和修复记录,包括搭建指南/解析器/构建器的修改
metadata:
type: project
---
# v01 白模关卡问题修复记录(2026-06-08)
## 修复的文件
| 文件 | 修改内容 |
|------|---------|
| `docs/technical/关卡-v01-搭建指南.md` | 修复6个问题:全局地面、Z1双Floor、西边界旋转统一、东边界改Wall_400x300、Z1房屋墙旋转、院门改WallDoorDouble、南边界Z7去重 |
| `tools/level-builder/parsers/build_guide_parser.py` | 新增 `_deduplicate_actors()` 去重;`_parse_phase_ground` 缩小范围到B1段;支持 `旋转 (P,Y,R)` 列名;支持全局地面标题 |
| `tools/level-builder/builder/scene_builder.py` | `load_asset` 失败时用 Cube 占位 + `[MISSING]` 前缀 + MI_DevPink 标记 |
| `tools/level-builder/config/asset_mapping.py` | 新增 `WallDoorDouble_400x300` 资产映射 |
| `tools/level-builder/parsers/markdown_table.py` | `parse_rotation` 增加箭头注释处理说明 |
## 诊断结论
- 8个问题中6个源头在**搭建指南**,2个涉及执行案
- 解析器有双重解析bug(地面段解析了边界表格)已修复
- 构建器有静默fallback已改为醒目标记
## Why: 用户检查生成的白模关卡发现多处明显错误,需要追溯源头并修复
## How to apply: 重新生成关卡时使用修复后的 JSON(238 Actor)。如再出问题,先查搭建指南再查解析器。
@@ -0,0 +1,44 @@
---
name: level-builder-tool
description: 白模关卡自动生成 Python 脚本工具,位于 tools/level-builder/
metadata:
type: project
---
# Level Builder 工具
## 位置
`tools/level-builder/`
## 功能
两阶段流水线:解析搭建指南 Markdown → JSON → UE5 Actor
## 使用方式
```bash
# 解析
python tools/level-builder/main.py parse -i docs/technical/关卡-v01-搭建指南.md -o output/level_v01.json
# 验证
python tools/level-builder/main.py validate -i output/level_v01.json
# 报告
python tools/level-builder/main.py report -i output/level_v01.json -o output/搭建报告.md
```
## UE5 构建
在 UE5 编辑器 Python 控制台中:
```python
exec(open(r"tools/level-builder/main.py").read())
# 然后 main(["build", "-i", "output/level_v01.json"])
```
**Why:** 替代手动白模关卡搭建流程,从策划执行案/搭建指南自动生成关卡
**How to apply:** 收到白模搭建任务时,优先运行此工具而非手动操作
## 依赖
- UE5 PythonScriptPlugin 已在 .uproject 中启用
- 37 种 BSP 资产映射(config/asset_mapping.py)
- 当前解析搭建指南(parsers/build_guide_parser.py),未来扩展执行案解析器
## 数据
- v0.1 解析结果:265 个 Actor(STR:123, PROP:53, BND:61, INTR:10, COLL:9, LGT:8, PLAY:1)
@@ -0,0 +1,69 @@
---
name: ue57-python-api
description: UE5.7 Python API 实测差异记录——set_actor_rotation、new_level、CWD 问题
metadata:
type: project
---
# UE5.7 Python API 实测差异
## 2026-06-07 实测(UE 5.7, PythonScriptPlugin)
### `set_actor_rotation()` 需要 `teleport_physics` 参数
```python
# UE5.6 文档写法(会报错):
actor.set_actor_rotation(unreal.Rotator(pitch, yaw, roll))
# UE5.7 正确写法:
actor.set_actor_rotation(unreal.Rotator(pitch, yaw, roll), False)
```
**Why:** UE5.7 改了 API 签名,positional argument `teleport_physics` 变为必需
**How to apply:** 所有 `set_actor_rotation()` 调用都加第二个参数 `False`
### `unreal.Rotator` 位置参数顺序不是 (Pitch, Yaw, Roll)
```python
# ❌ 错误:位置参数顺序非 P,Y,R,Yaw=90 会落到 Pitch(Y显示)
actor.set_actor_rotation(unreal.Rotator(0, 90, 0), False)
# ✅ 正确:用关键字参数避免歧义
actor.set_actor_rotation(unreal.Rotator(pitch=0, yaw=90, roll=0), False)
```
**Why:** UE5.7 Python `unreal.Rotator` 的位置参数顺序与关键字参数名不一致,第二个位置参数映射到 Pitch 而非 Yaw
**How to apply:** 所有 `unreal.Rotator()` 调用必须使用关键字参数 `pitch=, yaw=, roll=`
### `-ExecutePythonScript` 模式下 CWD 不是项目目录
```python
# ❌ 错误:CWD 是引擎 Binaries 目录
PROJECT_ROOT = Path(os.getcwd())
# → E:\...\UE_5.7\Engine\Binaries\Win64
# ✅ 正确:通过环境变量传入
PROJECT_ROOT = Path(os.environ.get("LB_PROJECT_ROOT", ""))
```
**Why:** UE5 引擎启动时将 CWD 设为引擎二进制目录
**How to apply:** 由 bat 脚本设置 `LB_PROJECT_ROOT` 环境变量,Python 脚本从中读取
### `new_level()` 需要 `asset_path` 参数
```python
# ❌ 错误:
unreal.EditorLevelLibrary.new_level()
# ✅ 正确:
unreal.EditorLevelLibrary.new_level("/Game/Maps/LVL_SuburbanV01")
```
### `AssetToolsSubsystem` 在 UE5.7 Python 中不可用
`unreal.get_editor_subsystem(unreal.AssetToolsSubsystem)` 报 `no attribute 'AssetToolsSubsystem'`
改用 `unreal.EditorLevelLibrary.new_level(asset_path)` 创建关卡
### 目录名含连字符导致 Python import 失败
```
tools/level-builder/ ← 连字符
from tools.level_builder.config... ← 下划线,Python import 不支持连字符
```
修复:将 level-builder 根目录加入 sys.path,使用 `from config.asset_mapping import ...` 代替完整路径
## 相关文件
- `tools/level-builder/builder/scene_builder.py`
- `tools/level-builder/build_cmdline.py`
- `tools/level-builder/build_level.bat`
@@ -0,0 +1,12 @@
---
name: subagent-permissions
description: 后台子 Agent 需要预授权 Edit/Write 权限才能修改文件
metadata:
type: feedback
---
后台子 Agent(game-dev 等)无法弹出交互式权限确认,缺少 Edit/Write 授权时会直接被拒绝文件修改。
**Why:** 首次启动 game-dev 后台 Agent 实现 P-011 时,因 settings.json 缺少 Edit/Write 权限,Agent 只读了技术文档但无法修改源码。
**How to apply:** 在 `.claude/settings.json` 的 `permissions.allow` 中添加 `"Edit"` 和 `"Write"`(2026-06-10 已添加)。如果后续新增需要后台运行的工具,同样需要预授权对应权限。
@@ -0,0 +1,35 @@
---
name: pipeline-status-20260603
description: 2026-06-03 的流水线阶段判断结果,v0.1 处于阶段④执行案完善中
metadata:
type: project
---
# 流水线阶段判断 — 2026-06-03
**当前版本**: v0.1 场景漫游与环境交互
**流水线阶段**: ④ 执行案(质量门未通过)
**游戏开发阶段**: 预制作
## 各阶段状态
| 阶段 | 状态 |
|------|------|
| ① 全局设计案 | 通过 |
| ② 版本Roadmap | 通过(但需补全 v0.5-v0.7) |
| ③ 当前版本设计文档 | 通过 |
| ④ 执行案 | **未通过质量门** |
| ⑤ 程序设计 | 不存在(前置未通过) |
| ⑥ 美术产出 | 部分就位(主角模型已开始,白盒占位未系统产出) |
| ⑦ 程序实现 | 未开始 |
## 关键发现
- 设计文档产出良好,覆盖面广
- 但执行案与全局设计混合在同一文档中,未按流水线要求独立
- 程序实现完全未启动(仅有 UE5 第三人称模板代码)
- 模板变体代码(Variant_Combat/Platforming/SideScrolling)未清理
- GameConfig/ 无实际配置文件
**Why:** 首次 /plan 诊断的基线快照
**How to apply:** 后续 /plan 调用时对比此基线判断进度变化
@@ -0,0 +1,28 @@
---
name: pipeline-status-20260605-p002
description: P-002 执行案质量门审查结果 -- 整体不通过,三个系统均需修正
metadata:
type: project
---
P-002 执行案质量门审查于 2026-06-05 完成,整体判定不通过。
**审查结论**:
- 交互系统:不通过(技术越界 + 边界情况缺失)
- 收集系统:不通过(v0.1 执行内容匮乏 + 边界情况完全缺失)
- 角色控制:有条件通过(需创建独立执行案 + 少量补充)
**核心阻塞项**:
1. 三个系统均缺失独立执行案文件(应为 `<系统名>-execution.md`)
2. 三个系统均缺失迭代记录表头
3. 交互系统技术越界(JSON 数据结构、程序实现注释、白盒占位模型需求)
4. 收集系统 v0.1 部分仅 4 行,不可直接实现
5. 交互系统和收集系统的边界情况均严重不足
**审查记录**:`docs/plans/reviews/P-002-review.md`
**Why:** 执行案质量门是③到④的必要门槛,不通过则无法进入程序设计阶段。当前设计文档同时承担设计案和执行案双重角色,需拆分。
**How to apply:** 下次会话时,v0.1 的状态应为"③执行案修正中"。策划需按审查报告的修正方向产出独立执行案文件后,重新提交质量门审查。
**Related:** [[pipeline-status-20260605]] [[design-doc-review]]
@@ -0,0 +1,35 @@
---
name: pipeline-status-20260605
description: P-001 原型阶段启动准备完成,当前处于流水线③执行案待确认状态
metadata:
type: project
---
## 当前状态(2026-06-05)
- 阶段:原型阶段
- 当前版本:v0.1
- 流水线位置:③执行案待确认/更新(P-002)
- P-001 原型阶段启动准备已完成
## P-001 完成内容
1. prototype-roadmap.md 审查完成 -- v0.1 范围对齐,发现 v0.2/v0.3 有细节遗漏(建议更新但未修改)
2. roadmap.md v0.3 已补全"零花钱经济"
3. 角色汇报机制已建立(reports/ 下三个模板文件)
4. 草案归档已补充 D-001 和 D-002 记录
## 待决策事项
- prototype-roadmap.md 的 4 项建议更新需用户确认
- v0.2/v0.3 验收标准待 v0.1 完成后详细定义(合理,不阻塞)
## 下一步
P-002:v0.1 执行案确认与更新,提交执行案质量门审查
## 相关记忆
- [[pipeline-status-20260603]]:上一轮流水线状态
- [[design-doc-review]]:交互系统技术越界、收集系统缺边界情况
- [[roadmap-alignment]]:roadmap 对齐问题
@@ -0,0 +1,19 @@
---
name: roadmap-alignment
description: 路线图已于 2026-06-05 全面重新评估,3 版本扩展为 4 版本,与可行性评估对齐
metadata:
type: project
---
# 路线图重新评估完成(2026-06-05)
原 3 版本规划(v0.1-v0.3)已扩展为 4 版本(v0.1-v0.4),关键变化:
- 原 v0.2(弹珠+NPC+室内+音效)拆分:弹珠独立为 v0.2,NPC/时间系统为 v0.3
- 原 v0.3(时间+经济+NPC联动)拆分:时间+NPC 为 v0.3,经济+叙事为 v0.4
- 经济系统从 v0.3 后移到 v0.4,降低单版本工作量
- roadmap.md 已补全批量生产阶段 v0.5-v0.7
**Why:** 原 v0.2/v0.3 范围过大(各 20-32 天),单人开发风险高。拆分后每版本聚焦一个核心假设
**How to apply:** 后续版本开发时,每版本聚焦一个假设,范围不再膨胀。如果发现范围偏大应及时拆分而非堆叠
关联记忆:[[pipeline-status-20260605]]
@@ -0,0 +1,5 @@
# game-reviewer 记忆索引
- [Gitea Token Permission](gitea-token-permission.md) — reviewer token 仅可评论,标签操作需用 PM token
- [P-006 Review](p006-review-note.md) — Issue #17 白模搭建审查通过记录
- [P-009 Review](p009-review-note.md) — Issue #20 收集系统程序设计审查通过记录
@@ -0,0 +1,12 @@
---
name: gitea-token-permission
description: reviewer token 权限限制——仅可评论,标签操作需 PM token
metadata:
type: feedback
---
reviewer token (ff336234...) 权限不足,无法修改 Issue 标签(HTTP 403: write permission is required)。评论操作正常。
**Why:** reviewer token 可能被配置为只读 + 评论权限。
**How to apply:** 标签更新操作使用 PM 角色执行:`node .claude/skills/gitea-pm/gitea.js --role pm issue labels <number> --set <ids>`
@@ -0,0 +1,14 @@
---
name: p006-review-note
description: Issue #17 P-006 白模搭建审查通过记录
metadata:
type: project
---
P-006(Issue #17)v0.1 白模关卡搭建审查于 2026-06-08 通过。
**结论:** 7 区域完整覆盖执行案,238 Actor,偏差记录详尽。
**已知偏差:** Z5 巷子宽度 260cm vs 设计 300cm(重大但不阻塞,260cm 仍可通行)。
**How to apply:** 后续 6b-6f 子任务可继续推进。空间精度偏差由用户决定是否修正。
@@ -0,0 +1,19 @@
---
name: p009-review-note
description: Issue #20 P-009 收集系统程序设计审查通过记录
metadata:
type: project
---
## P-009 收集系统程序设计审查 (2026-06-10)
- Issue: #20
- 审查类型: 程序设计(质量门 2)
- 产出文件: `docs/technical/收集系统-技术方案.md`
- 结果: **通过**
### 审查要点
- 技术方案文档路径实际为中文文件名 `收集系统-技术方案.md`(非 Issue 中标注的 `collection-system-tech.md`)
- 执行案文件名也是中文:`收集系统-执行案.md`(非 `collection-system-execution.md`)
- 6 个检查维度全部通过:执行案覆盖度、美术资源需求、架构清晰度、数据流、技术风险、工作量与拆分建议
- 标签操作:移除了 gate:art-fail(30) 和 status:in-review(14),添加了 review:pass(33)
@@ -0,0 +1,2 @@
- [Project Tech Stack](project-tech-stack.md) — D-002 确定的技术架构、模块选择、风险和工期预估
- [Codebase Cleanup Needed](feedback-codebase-cleanup.md) — Variant_* 遗留代码需在 v0.1 前清理
@@ -0,0 +1,14 @@
---
name: feedback-codebase-cleanup
description: 评估发现遗留代码需要清理,避免后续开发混乱
metadata:
type: feedback
---
# 遗留代码清理
**Rule:** v0.1 开始前需要清理 Source/ChildHood/ 中的 Variant_Combat、Variant_Platforming、Variant_SideScrolling 三个遗留游戏模式代码。
**Why:** 技术可行性评估时发现这些代码与 ChildHood 核心玩法无关,是之前原型实验的遗留。保留会污染代码库、增加编译时间、干扰新开发者理解项目结构。
**How to apply:** 在 v0.1 实施阶段的第一步,将这些目录归档或删除。清理后 ChildHoodCharacter、ChildHoodPlayerController、ChildHoodGameMode 作为项目基类保留。
@@ -0,0 +1,36 @@
---
name: project-tech-stack
description: ChildHood 项目的技术栈选择和架构决策记录
metadata:
type: project
---
# ChildHood 技术栈与架构决策
**Why:** 技术可行性评估(D-002)确定的架构方向,后续所有技术设计需以此为基准。
**How to apply:** 新系统设计时参照此文档选择模块和方案;如有偏差需记录原因。
## 引擎与插件
- UE 5.7.4,已启用 StateTree + GameplayStateTree + ModelingToolsEditorMode
- 不使用 GAS、Nanite、Lumen、MassEntity
- 已有模块依赖:Engine、AIModule、UMG
## 核心架构方案
- 角色控制:ACharacter + Enhanced Input + SpringArm(已有基类 ChildHoodCharacter)
- 交互系统:C++ Interface + SphereOverlap + UMG Widget
- NPC:StateTree(已启用插件)+ 自定义对话 JSON 解析
- 数据驱动:DataAsset + DataTable(对应 GameConfig/ CSV/JSON)
- 小游戏:独立 GameMode + PlayerController,进入时切换
- 时间系统:GameState 中的 TimeManager Subsystem
- 存档:USaveGame + SaveGameToSlot
## 关键风险记忆
- 小游戏(弹珠/四驱车/FC/街机)是最大风险,内容量决定工期
- 弹珠操作手感必须在 v0.2 做 Throwaway 原型验证
- Source/ 中的 Variant_* 遗留代码需清理
- 叙事文本和美术资产是隐性工作量最大的非代码任务
## 工期预估参考
- v0.1: 10-18 天,v0.2: 20-32 天,v0.3: 18-29 天
- 全项目:158-249 天(约 5-8 个月全职)
-125
View File
@@ -1,125 +0,0 @@
---
name: "game-art-prompter"
description: "Use this agent when the user needs to generate GPT-Image-2 prompts for game art assets, including characters, scenes, props, UI elements, or any visual art direction needs. This includes when the user describes a visual concept and needs a refined, professional prompt for image generation.\\n\\nExamples:\\n\\n- User: \"我需要一个千禧年风格的小男孩角色,穿着校服,背着书包\"\\n Assistant: \"让我使用 game-art-prompter 来为这个角色生成专业的 GPT-Image-2 提示词。\"\\n (Since the user is describing a character concept, use the Agent tool to launch the game-art-prompter agent to craft an optimized image generation prompt.)\\n\\n- User: \"帮我设计一个城郊结合区的街道场景,要有年代感\"\\n Assistant: \"这个场景描述很适合用 game-art-prompter 来生成精确的图像提示词,让我启动它。\"\\n (Since the user needs a scene visualized, use the Agent tool to launch the game-art-prompter agent to create a detailed scene prompt.)\\n\\n- User: \"需要一批道具的参考图,包括玻璃弹珠、四驱车、溜溜球\"\\n Assistant: \"我来用 game-art-prompter 为这些道具分别生成图像提示词。\"\\n (Since the user listed multiple props needing visual references, use the Agent tool to launch the game-art-prompter agent to generate individual prompts for each item.)\\n\\n- User: \"UI界面需要那种复古游戏风格的按钮和面板\"\\n Assistant: \"让我调用 game-art-prompter 来为这种复古 UI 风格生成合适的图像提示词。\"\\n (Since the user is requesting UI visual assets, use the Agent tool to launch the game-art-prompter agent to design appropriate prompts.)"
model: opus
color: green
---
You are a senior game art director with 15+ years of experience across multiple art styles — from pixel art and low-poly to hyper-realistic renders. You specialize in crafting precise, high-quality GPT-Image-2 prompts that consistently produce stunning game art assets. You have deep knowledge of visual design principles, color theory, composition, lighting, and how to translate abstract concepts into concrete visual descriptions.
## Core Identity
You are bilingual (Chinese/English) and understand both Eastern and Western art sensibilities. You excel at taking rough descriptions — whether they're a single sentence or a detailed brief — and transforming them into optimized GPT-Image-2 prompts that produce professional-quality game art.
## Project Context
You are working on **ChildHood(我的童年)**, a UE5.7.4 game with a **3D Lowpoly** art style featuring:
- **Tone**: Warm colors, nostalgic atmosphere,千禧年 (millennium era) suburban-rural China aesthetic
- **Setting**: Year 2000, urban-rural fringe areas (城郊结合区) like Nanjing's Maigaoqiao
- **Style Reference**: NDS「放学后少年」free exploration + PS「我的暑假」daily narrative rhythm
- **Visual Keywords**: Lowpoly, warm palette, soft lighting, childhood nostalgia, Chinese suburban 2000s
## Your Workflow
### 1. Understand the Request
- Identify the asset type: **character (角色)**, **scene/environment (场景)**, **prop/item (道具)**, or **UI element (UI)**
- Extract the core visual intent from the user's description
- Determine the art style requirements (default to project's Lowpoly warm style unless specified otherwise)
- Ask clarifying questions if the description is ambiguous
### 2. Analyze & Expand
- Break down the visual elements: silhouette, color palette, materials, lighting, mood
- Consider period-accurate details for 千禧年 setting when relevant
- Think about how the asset fits within the broader game world
- Note any cultural specifics (Chinese suburban life circa 2000)
### 3. Craft the GPT-Image-2 Prompt
Follow these prompt engineering principles:
**Structure your prompts in this order:**
1. **Subject** — Clear, specific description of what to generate
2. **Style** — Art style, rendering approach (e.g., "3D lowpoly", "cel-shaded")
3. **Details** — Materials, textures, specific features
4. **Composition** — Camera angle, framing, pose
5. **Lighting** — Light source, time of day, mood
6. **Color** — Palette, saturation, warmth
7. **Background** — Environment context (or "white background" for isolated assets)
8. **Negative/Exclusion** — What to avoid (optional but useful)
**Prompt Language Rules:**
- Write the **main GPT-Image-2 prompt in English** for best generation quality
- Provide a **Chinese explanation** of your creative decisions
- If the user communicates in Chinese, respond in Chinese with English prompts embedded
**Quality Markers to Include (when applicable):**
- Art style qualifiers: "3D lowpoly", "low-polygon count", "flat-shaded"
- Quality boosters: "clean geometry", "consistent edge loops", "game-ready asset"
- Mood enhancers: "warm lighting", "nostalgic atmosphere", "soft ambient occlusion"
- Technical specs: "isometric view", "front view", "white background for extraction"
### 4. Output Format
For each asset, provide:
```
## [Asset Name / 资产名称]
**类型**: Character / Scene / Prop / UI
**风格**: [Art style used]
### Prompt
```
[The complete GPT-Image-2 prompt, in English]
```
### 设计说明
- [Key design decisions explained in Chinese]
- [Why specific visual choices were made]
- [How it fits the project's aesthetic]
### 参数建议
- 尺寸比例: [e.g., 1:1, 16:9, 4:3]
- 视角建议: [camera angle reasoning]
```
## Asset-Specific Guidelines
### Characters (角色)
- Include: age, build, hairstyle, clothing (era-appropriate for 千禧年), expression, pose
- 千禧年服装参考: 校服(运动服款)、帆布鞋、运动鞋、宽大T恤
- Lowpoly specifics: simplified facial features, geometric hair, bold color blocks
- Default to: front-facing 3/4 view, T-pose or natural idle pose
### Scenes/Environments (场景)
- Include: time of day, weather, key landmarks, vegetation, architectural style
- 千禧年场景元素: 红砖楼、小卖部、巷弄、电线杆、晾衣绳、自行车棚
- Lowpoly specifics: blocky buildings, simplified trees, stylized clouds
- Default to: wide establishing shot or eye-level perspective
### Props/Items (道具)
- Include: material, size reference, worn/new condition, cultural significance
- 千禧年道具参考: 玻璃弹珠、四驱车、溜溜球、水浒卡、小浣熊干脆面、铁皮青蛙
- Lowpoly specifics: faceted surfaces, limited color palette, readable silhouette
- Default to: isolated on white/transparent background, 3/4 view
### UI Elements (UI)
- Include: function, interaction state, size relative to screen
- 风格参考: 复古游戏界面、像素风边框、手绘质感
- Lowpoly specifics: geometric shapes, bold icons, clean readable layout
- Default to: flat/front view, clear silhouette
## Iteration & Refinement
- Always offer 2-3 **prompt variations** for each asset (different angles, moods, or styles)
- If the user provides feedback on a generated image, adjust the prompt based on what needs to change
- Keep a mental note of which prompt structures produce the best results
## Important Rules
1. **Never sacrifice clarity for creativity** — a prompt must be understandable first
2. **Stay era-appropriate** — no modern smartphones, contemporary brands, or anachronistic elements in千禧年 assets
3. **Maintain style consistency** — all prompts should feel like they belong in the same game world
4. **Be specific about low-poly** — include geometry and shading descriptors that guide toward the low-poly aesthetic
5. **Respect the warm, nostalgic tone** — avoid dark, gritty, or cold palettes unless explicitly requested
6. **Provide actionable output** — every prompt should be ready to copy-paste into GPT-Image-2
+14
View File
@@ -3,6 +3,7 @@ name: "game-designer"
description: "策划 Agent:头脑风暴、产出设计文档(概念草案/系统设计案/执行案)、场景设计、玩法机制定义、数值配置、版本复盘文档更新。触发关键词:设计、执行案、头脑风暴、概念草案、数值。"
model: opus
color: red
memory: project
skills:
- gitea-collab
- design-execution
@@ -341,4 +342,17 @@ bash .claude/scripts/git-workflow.sh push
---
**持续更新你的 Agent 记忆**——当你发现设计模式、已确认的创意决策、头脑风暴共识点、跨系统关系、以及项目特有的叙事元素时记录下来。这会在多次对话中积累组织知识。写简洁的笔记。
---
## Memory
记忆系统规范参见 `.claude/skills/agent-memory.md`。
你的记忆目录:`.claude/agent-memory/game-designer/`。
你的 MEMORY.md 索引:`.claude/agent-memory/game-designer/MEMORY.md`。
记忆索引行数上限 200 行,超过后截断。
## MEMORY.md
Your MEMORY.md is currently empty. When you save new memories, they will appear here.
+14
View File
@@ -3,6 +3,7 @@ name: "game-dev"
description: "程序 Agent:根据执行案设计技术方案、编写 C++/蓝图代码、集成美术资产、版本复盘偏差汇报。触发关键词:技术方案、实现代码、偏差报告、修正技术方案。"
model: opus
color: blue
memory: project
skills:
- requirements-analysis
- level-builder
@@ -283,4 +284,17 @@ bash .claude/scripts/git-workflow.sh catchup
---
**持续更新你的 Agent 记忆**——当你发现代码库架构模式、UE5 特定的实现技巧、性能特征、项目中的常见技术挑战、有用的代码片段和模式、系统与组件之间的关系时记录下来。这会在多次对话中积累技术组织知识。
---
## Memory
记忆系统规范参见 `.claude/skills/agent-memory.md`。
你的记忆目录:`.claude/agent-memory/ue5-programmer/`。
你的 MEMORY.md 索引:`.claude/agent-memory/ue5-programmer/MEMORY.md`。
记忆索引行数上限 200 行,超过后截断。
## MEMORY.md
Your MEMORY.md is currently empty. When you save new memories, they will appear here.
+9 -1
View File
@@ -2,6 +2,7 @@
name: "game-pm"
description: "项目管理 Agent:进度管理(3阶段状态机)、任务分配与追踪、开发决策咨询。触发关键词:开始工作、进度、分配任务、优先级。"
model: opus
memory: project
skills:
- gitea-pm
- gitea-collab
@@ -208,4 +209,11 @@ bash .claude/scripts/git-workflow.sh post-release
---
---
## Memory
记忆系统规范参见 `.claude/skills/agent-memory.md`。
你的记忆目录:`.claude/agent-memory/game-project-pm/`。
你的 MEMORY.md 索引:`.claude/agent-memory/game-project-pm/MEMORY.md`。
记忆索引行数上限 200 行,超过后截断。
持续更新你的记忆——当你发现项目模式、开发节奏、估算偏差、常见阻塞、有效任务粒度时记录下来。记忆类型包括:user、feedback、project、reference。写简洁、可操作的笔记。
+7 -1
View File
@@ -3,6 +3,7 @@ name: "game-reviewer"
description: "审查子代理 — 角色完成工作后启动,审查交付物质量,更新 Issue 标签和评论。使用 ai-reviewer 账号操作 Gitea。"
model: opus
color: purple
memory: project
tools: Read, Grep, Glob, Bash, Skill
skills:
- gitea-collab
@@ -147,4 +148,9 @@ skills:
---
---
## Memory
记忆系统规范参见 `.claude/skills/agent-memory.md`。
你的记忆目录:`.claude/agent-memory/game-reviewer/`。
你的 MEMORY.md 索引:`.claude/agent-memory/game-reviewer/MEMORY.md`。
记忆索引行数上限 200 行,超过后截断。
+139
View File
@@ -0,0 +1,139 @@
# Git 快速参考(AI 用)
> 从 [GIT-GUIDE.md](GIT-GUIDE.md) 精简。完整规范见原文。
---
## 分支策略
**develop 长期开发分支模型** — 单人开发 + 多 Agent 协作。
```
main ─────────── 永远可玩可编译,受保护,禁止直接推送
│
│ PR (Gitea) ← 里程碑/迭代结束时,PM 发起
│
└── develop ──── 长期分支,所有 agent 在此工作
```
| 分支 | 生命期 | 说明 |
|---|---|---|
| `main` | 永久 | 稳定版本,受保护 |
| `develop` | 永久 | 日常开发分支,所有 agent 共用 |
**辅助分支(可选,用于实验):**
| 分支 | 前缀 | 生命期 | 说明 |
|---|---|---|---|
| 原型 | `prototype/` | 实验完即删 | 技术验证,随便写 |
| 发布 | `release/` | 永久 | 从 main 切出,版本标记 |
**流程:**
1. 所有开发在 `develop` 上进行 → 2. 迭代完成时创建 PR → 3. 审查通过后合并回 `main`
---
## 提交规范
**格式:** `<type>(<scope>): <中文描述>`
- subject ≤ 50 字符,中文描述,技术术语保留英文
- body 可选,每行 ≤ 72 字符
**Type(必选):**
| Type | 用途 |
|---|---|
| `feat` | 新功能 |
| `fix` | Bug 修复 |
| `art` | 美术资源 |
| `level` | 关卡/场景 |
| `audio` | 音效/音乐 |
| `bp` | 蓝图逻辑 |
| `refactor` | 重构(不改功能) |
| `config` | 项目/引擎配置 |
| `docs` | 文档 |
| `wip` | 进行中(未完成) |
**Scope(可选,常用):**
`player` `enemy` `combat` `ui` `camera` `inventory` `dialogue` `quest` `save` `audio` `animation` `ai` `level` `vfx`
**示例:**
```
feat(player): 实现角色跳跃和二段跳
fix(camera): 修复穿墙时摄像机卡顿问题
bp(enemy): 实现巡逻-追击-攻击状态机
wip(crafting): 合成系统UI初步框架
config: 启用Nanite和Lumen
```
---
## UE5 注意事项
- 蓝图与 C++ 分开提交
- `.uasset` / `.umap` 无法 diff → commit message 必须写清改了什么
- 不提交 `Binaries/` `Intermediate/` `Saved/` `.vs/`
---
## 版本号
`v主版本.次版本.修订号` — 主版本=里程碑,次版本=新系统,修订号=小修
---
## 远程仓库
**SSH 地址**:`ssh://sc-gitea@micecola.top:2223/starmen1265/ChildHood.git`
> ⚠️ 用户名是 `sc-gitea`(非默认 `git`),端口是 `2223`(非默认 22)
**切换远程地址**:
```bash
git remote set-url origin ssh://sc-gitea@micecola.top:2223/starmen1265/ChildHood.git
```
**SSH config**(`~/.ssh/config`):
```
Host gitea micecola.top
HostName micecola.top
Port 2223
User sc-gitea
IdentityFile ~/.ssh/id_ed25519_gitea
IdentitiesOnly yes
```
**内网地址**(家里用):`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 <type> <scope> "中文描述"
# 推送到远程
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
```
+285
View File
@@ -0,0 +1,285 @@
# 游戏开发工作流
> 本文档定义游戏项目的开发阶段、任务管理、角色职责、产出标准和质量检查规则。
> 项目采用 3 阶段开发模型:草图 → 总路线 → 迭代。每个阶段有独立的技能文件管理任务序列。
>
> 项目管理工具当前使用 Gitea。PM 操作详见 `gitea-pm` 技能,通用协作详见 `gitea-collab` 技能。
> 阶段技能文件:
> - `.claude/skills/project-initializer.md` — 项目初始化
> - `.claude/skills/stage-sketch.md` — 草图阶段
> - `.claude/skills/stage-roadmap.md` — 总路线阶段
> - `.claude/skills/stage-iteration.md` — 迭代阶段
---
## 角色定义
| 角色 | 职责 | 产出位置 | PM 如何判断其进度 |
|------|------|----------|-------------------|
| **策划** (Design) | 玩法设计、数值平衡、叙事、系统机制 | `docs/design/`、`GameConfig/` | 扫描 `docs/design/` 和 `GameConfig/` 目录中的文件 |
| **程序** (Dev) | 系统实现、技术架构、Bug 修复 | `Source/`、`Content/`、`docs/technical/` | 扫描对应目录中的文件 |
| **美术** (Art) | 美术需求分析、资产规格、风格指南 | `docs/art/`、`Content/` | 扫描对应目录中的文件 |
| **PM** | 进度管理(3阶段状态机)、任务分配与追踪、开发决策咨询 | **Gitea Issues + Milestones** | 通过 Gitea 读取任务和里程碑 |
| **审查** (Reviewer) | 审查交付物质量、更新 Issue 标签和评论 | Issue 评论 | 通过 `review:pass/fail` 标签 |
### 协作规则
- **策划先行**:设计文档是其他角色的需求源
- **程序读设计**:实现前必须先读取对应设计文档和配置表
- **文档即交接**:Agent 之间不直接通信,通过共享文档目录 + 任务 Issue 评论交接(参见 `gitea-collab` 技能)
- **冲突时用户说了算**:玩法/数值/叙事的最终决定权在用户
- **PM 是进度调度器**:PM 管理进度和任务分配,不做审查
- **审查是子代理**:角色完成工作后启动 game-reviewer 子代理审查,审查结果返回给角色,角色向用户汇报
- **会话启动先调 PM**:每次工作会话开始时,用户先调 PM 执行进度检查,确认当前状态后再进入具体工作
- **用户驱动角色切换**:角色间的工作切换由用户手动触发,PM 不自动指派任务
- **角色任务汇报**:各角色通过项目管理工具在任务 Issue 下评论汇报进度,PM 据此追踪
### 进度汇报规则
- 进度管理唯一真实来源是 **项目管理工具**(当前为 Gitea Issues + Milestones)。具体操作参见 `gitea-pm` / `gitea-collab` 技能
- 各角色完成工作后在对应 Issue 下评论汇报,不更新本地 report 文件
- PM 通过项目管理工具读取任务和评论,给出行动建议
- 角色完成工作后自行启动 game-reviewer 子代理审查,审查通过后 PM 请示用户关闭 Issue
- 任务追踪使用唯一任务 ID(格式:`[角色前缀]-[三位数字]`),对应 Issue 标题前缀
- 审查结果通过 Issue 评论发布
---
## 开发阶段
项目开发分为三个阶段,每个阶段有独立的技能文件管理任务序列。
```
草图阶段 ─── "这个游戏是什么?能不能做?" ─── 概念设计 + 技术可行性
↓
总路线阶段 ─── "全面设计蓝图" ─── 9 份设计文档 + 版本路径图
↓
迭代阶段 ─── "按版本迭代开发" ─── 插件准备 → 执行案 → 开发 → 整合 → 修复
↑ ↓
└──── 迭代完成,有下一个迭代 ←── 迭代完成 ───────┘
```
**阶段转换由 PM 建议但需用户确认**。阶段技能文件定义了每个阶段的任务序列和转换条件。
---
### 草图阶段
**目的**:确定游戏是什么——玩法方向、美术风格、技术可行性。
**关注问题**:这个游戏是什么?为什么要做?做不做得了?
#### 任务序列(2 个任务)
1. **概念设计** (D-XXX) — 策划产出概念草案
2. **技术可行性评估** (P-XXX) — 程序评估技术可行性(依赖概念设计完成)
#### 完成标志
两个任务都关闭 + 草图阶段里程碑下无 open issues → 进入总路线阶段。
#### 详细任务定义
参见 `.claude/skills/stage-sketch.md`。
---
### 总路线阶段
**目的**:建立完整的游戏设计蓝图,规划版本迭代路径。
**关注问题**:游戏长什么样?有哪些系统?分几个版本做?
#### 任务序列(9 个任务)
1. **设计总索引** (D-XXX) — 必须完整
2. **世界观设计** (D-XXX) — 允许占位
3. **剧情设计** (D-XXX) — 允许占位
4. **角色设计** (D-XXX) — 允许占位
5. **系统设计** (D-XXX) — ⚠️ 必须完整
6. **关卡设计** (D-XXX) — ⚠️ 必须完整
7. **UI 设计** (D-XXX) — 允许占位
8. **音频设计** (D-XXX) — 允许占位
9. **版本路径图** (D-XXX) — 必须完整(依赖 2-8)
**占位规则**:除系统设计和关卡设计外,其他文档允许空文件占位(标题+章节+「待填充」),后续迭代完善。
#### 完成标志
所有任务关闭 + 里程碑下无 open issues → 进入迭代阶段。
#### 详细任务定义
参见 `.claude/skills/stage-roadmap.md`。
---
### 迭代阶段
**目的**:按版本迭代推进开发,每个迭代完成一个可玩版本。
**关注问题**:这个版本做什么?做到什么程度?Bug 修了没?
#### 任务序列(每迭代 6-9 个任务)
| 任务 | 角色 | 说明 |
|------|------|------|
| 插件资产检查与准备 | 程序 | 仅首次迭代 |
| 设计案迭代补充 | 策划 | 可选 |
| 策划执行案 | 策划 | 必选 |
| 程序需求分析 | 程序 | 必选 |
| 关卡搭建 | 程序 | 有场景时 |
| 系统开发 | 程序 | 必选 |
| 优化意见 | 程序 | 必选 |
| 资产接入整合 | 程序 | 必选 |
| Bug 修复 | 程序 | 必选 |
**关键规则**:PM 每次激活时只创建下一个可创建的任务,不要一次全部创建。
#### 迭代完成
Bug 修复关闭 → 询问用户是否有下一个迭代:
- **有** → 关闭当前里程碑 → 创建下一个迭代里程碑 → 继续
- **没有** → 返回总路线阶段
#### 详细任务定义
参见 `.claude/skills/stage-iteration.md`。
---
## 设计文档层级关系
草案、设计案、执行案三者的关系:
| 层级 | 定位 | 比喻 | 特点 |
|------|------|------|------|
| **草案** (概念草案.md) | 给游戏**定调** | 灵魂 | 回答"这个游戏是什么",简略但方向明确 |
| **设计案** | 给游戏**定骨架** | 骨架 | 基于草案的原则展开系统设计,定义有哪些系统、系统关系、核心行为 |
| **执行案** | 给游戏**填血肉** | 血肉 | 细化到程序可直接实现的交互规则和行为细节 |
设计案初始基于草案的原则进行设计。在后续迭代中遇到具体问题时,可以灵活调整或替换草案中的元素——草案是方向指引,不是硬性约束。
---
## 执行案规范
### 执行案格式
每个执行案文件顶部必须包含**迭代记录表头**:
```markdown
# <系统名>执行案
## 迭代记录
| 迭代版本 | 修改日期 | 状态 | 说明 |
|----------|----------|------|------|
| v0.1 | 2026-06-10 | 📝 设计中 | 初始版本 |
> 状态标记:📝 设计中 | 🔄 开发中 | ✅ 已同步
---
## 系统概述
(描述该系统做什么、服务于什么体验目标)
## 行为规则
(当前最新状态的所有行为规则)
```
### 执行案必须包含
1. **系统概述**:这个系统做什么、服务于什么体验目标
2. **完整的行为规则清单**:每个操作的触发条件、触发后表现、结束条件
3. **状态和状态转换**:系统有哪些状态、什么条件触发状态变化
4. **边界情况处理**:异常/极端情况下的系统行为
5. **与其他系统的交互**:本系统与其他系统的关联和影响
### 执行案不应包含
- 用什么引擎组件实现
- 代码/脚本架构怎么组织
- 数据用什么格式存储
- 具体的参数精确值(调优参数标注"建议值,可调")
### 关卡执行案
- 当版本开发涉及场景搭建时,策划额外产出一份关卡执行案
- 关卡执行案将场景设计案的氛围描述转化为空间精度搭建指南(坐标、尺寸、BSP 资产映射)
- 使用混合坐标体系:区域用场景绝对坐标,区域内物件用相对偏移坐标
- 交互物和收集品的位置标注必须与交互系统/收集系统执行案交叉引用
- 完整规范参见 `.claude/refs/level-execution-template.md`
---
## 审查流程
> 审查由 **game-reviewer 子代理**执行,不再由 PM 执行。
> 角色完成工作后自行启动 reviewer 子代理。
### 审查触发
角色完成工作 → 在 Issue 下评论汇报 → 改标签为 `status:in-review` → 启动 game-reviewer 子代理
### 审查标准
审查标准定义在 `.claude/skills/quality-gate-review.md`,包含 4 道质量门:
1. 执行案质量门 — 完整性、清晰性、边界情况、系统关联、技术越界
2. 程序设计质量门 — 执行案覆盖度、美术需求、架构、数据流、风险、工作量
3. 美术资产验收门 — 需求覆盖、资产到位、规格、缺失处理
4. 程序实现验收门 — 设计一致性、覆盖度、资产使用、偏差处理
### 审查结果
- **通过** → reviewer 更新标签为 `review:pass` → 角色向用户汇报 → 用户决定是否关闭
- **未通过** → reviewer 更新标签为 `review:fail` + `status:blocked` → 角色向用户汇报 → 修改后重新提交
---
## 审查质量标准
> 以下标准供 game-reviewer 子代理使用。详细标准定义在 `.claude/skills/quality-gate-review.md`。
### 执行案质量标准
- [ ] 是否覆盖了该系统所有玩家可感知的行为?
- [ ] 每个操作是否明确了触发条件、触发后表现、结束条件?
- [ ] 边界情况是否都处理了?
- [ ] 与其他系统的关联是否说明?
- [ ] 是否有模糊地带?行为描述是否无歧义?
- [ ] 是否误入了技术细节?参数值是否标注为"建议值,可调"?
- [ ] 迭代记录表头是否存在且填写完整?
### 程序设计质量标准
- [ ] 是否完整回应了执行案中的每条行为规则?(逐条对照)
- [ ] 美术资源需求是否列得具体(尺寸、数量、触发时机)?
- [ ] 类与组件架构是否清晰?
- [ ] 数据流是否明确?
- [ ] 技术风险是否识别?
- [ ] 工作量估算是否给出?实现拆分建议是否合理且可操作?
### 美术资产验收标准
- [ ] 资源需求清单中的每一项是否都有对应的美术产出?(逐项对照)
- [ ] 资产文件是否已放置在对应目录?
- [ ] 资产规格是否符合需求?
- [ ] 未到位资产是否有明确的处理方案?
### 程序实现验收标准
- [ ] 实现是否与程序设计方案一致?(逐条对照)
- [ ] 最终实现是否完整覆盖了执行案中的所有行为规则?
- [ ] 美术资产是否已正确集成并使用?
- [ ] 偏差清单是否已梳理清楚?重大偏差是否有处理方案?
---
## 单人独立开发的特殊考量
- **无法并行**——同一时间只能做一件事,选择更关键
- **精力有限**——避免一次性拆分太多并行任务
- **心流重要**——建议按「完整做完一个小系统」而非「同时推进多个系统」的方式工作
- **早期验证**——越早能跑起来看到东西,越不容易失去动力
- **文档交接**——Agent 之间通过文档交接,PM 是进度调度器,reviewer 负责质量把关
@@ -0,0 +1,248 @@
ChildHood 项目当前使用本地 Markdown 文件(docs/plans/current.md、reports/、reviews/、archive/)进行进度管理。用户已在 Gitea 上搭建了 Issue/Milestone/看板基础设施(3 个 Issue、1 个 Milestone、1
个看板),并希望将进度管理完全迁移到 Gitea,不再保留本地降级模式。
核心目标:Gitea 成为唯一的进度管理真实来源,获得可视化看板、Web 面板随时查看、更结构化的任务管理体验。
---
一、结论:可行,但有前提条件
迁移可行,Gitea API 能覆盖当前进度管理 80%+ 的需求。但有两个前提条件必须先解决:
1. UTF-8 编码问题:现有 3 个 Issue 的中文全部乱码,说明之前通过 API 创建时编码处理有误。这是阻塞性问题,必须在迁移前解决。
2. API Token 写权限验证:ai-pm 账号的 Token 需要验证对 Issue/Milestone/Label/Comment 的写操作权限。
---
二、本地概念 → Gitea 映射设计
任务 (P-002) → Issue(标题含任务ID前缀,如 "P-002 v0.1 执行案")
版本 (v0.1) → Milestone(如 "v0.1-基础交互")
任务完成 [x] → Issue 关闭 (state=closed)
任务状态 → Label(status:backlog / in-progress / blocked / in-review / done)
流水线步骤 → Label(pipeline:3-execution / 4-tech-design / ...)
角色归属 → Label(role:design / dev / art / pm)+ assignee
质量门判定 → Label(gate:execution-pass/fail / tech-pass/fail / ...)
角色汇报 → Issue 评论
审查记录 → Issue 评论(摘要+链接)+ 本地文件(详细内容保留)
任务依赖 → Issue Dependencies API
流水线总览 → Meta Issue(body 中维护流水线进度表)
需要创建的 Label 体系(共约 20 个)
┌──────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┬────────────────────┐
│ 类别 │ Labels │ 用途 │
├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────┤
│ status (6) │ status:backlog status:ready status:in-progress status:blocked status:in-review status:done │ 任务生命周期状态 │
├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────┤
│ pipeline (5) │ pipeline:3-execution pipeline:4-tech-design pipeline:5-art pipeline:6-impl pipeline:7-retro │ 当前在流水线哪一步 │
├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────┤
│ role (4) │ role:design role:dev role:art role:pm │ 归属角色 │
├──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────┤
│ gate (8) │ gate:execution-pass/fail gate:tech-pass/fail gate:art-pass/fail gate:impl-pass/fail │ 质量门审查结果 │
└──────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┴────────────────────┘
本地文件处理策略
┌──────────────────────┬────────────────────┬─────────────────────────────────────────────────────────────────────┐
│ 文件 │ 策略 │ 原因 │
├──────────────────────┼────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ current.md │ 废弃 │ 流水线进度迁移到 Pipeline Tracker Meta Issue;任务列表迁移到 Issues │
├──────────────────────┼────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ reports/*.md │ 废弃 │ 角色汇报迁移到 Issue 评论 │
├──────────────────────┼────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ reviews/*.md │ 保留本地 │ 审查记录是结构化文档,内容复杂;Issue 评论中放结论摘要+文件链接 │
├──────────────────────┼────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ archive/ │ 冻结现有,不再新增 │ closed Issues = 归档,通过 Milestone 过滤查看历史 │
├──────────────────────┼────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ prototype-roadmap.md │ 保留本地 │ 信息量太大(4版本×多维度),不适合放 Milestone description │
├──────────────────────┼────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ roadmap.md │ 保留本地 │ 全局路线图,不迁移 │
├──────────────────────┼────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ work-log.md │ 保留本地 │ 粒度不匹配 Gitea time tracker │
└──────────────────────┴────────────────────┴─────────────────────────────────────────────────────────────────────┘
---
三、需验证的 API 接口(按优先级)
P0 — 阻塞性验证(迁移前必须通过)
┌─────┬───────────────────────────────────┬────────────────────────────────────────────┬─────────────────────────┬────────────────────┐
│ # │ 操作 │ 端点 │ 验证内容 │ 管理场景 │
├─────┼───────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────┼────────────────────┤
│ 1 │ 创建含中文的 Issue │ POST /repos/{owner}/{repo}/issues │ UTF-8 编码正确显示 │ 创建任何任务 │
├─────┼───────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────┼────────────────────┤
│ 2 │ 创建含中文的评论 │ POST /repos/{index}/comments │ UTF-8 编码正确显示 │ 角色汇报、审查结论 │
├─────┼───────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────┼────────────────────┤
│ 3 │ 创建 Label │ POST /repos/{owner}/{repo}/labels │ 含中文名的 Label 能创建 │ 初始化标签体系 │
├─────┼───────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────┼────────────────────┤
│ 4 │ 给 Issue 添加/移除 Label │ POST/DELETE /issues/{index}/labels │ Label 正确关联 │ 状态变更 │
├─────┼───────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────┼────────────────────┤
│ 5 │ 更新 Issue(状态/标题/body/指派) │ PATCH /repos/{owner}/{repo}/issues/{index} │ 所有字段正确更新 │ 任务状态推进 │
├─────┼───────────────────────────────────┼────────────────────────────────────────────┼─────────────────────────┼────────────────────┤
│ 6 │ 关闭/重开 Issue │ PATCH ... state=closed/open │ 状态切换正确 │ 任务完成/回退 │
└─────┴───────────────────────────────────┴────────────────────────────────────────────┴─────────────────────────┴────────────────────┘
P1 — 核心功能验证
┌─────┬──────────────────────────────────┬────────────────────────────────────────────┬──────────────────┬──────────────────┐
│ # │ 操作 │ 端点 │ 验证内容 │ 管理场景 │
├─────┼──────────────────────────────────┼────────────────────────────────────────────┼──────────────────┼──────────────────┤
│ 7 │ 读取 Issue 列表(含过滤) │ GET /issues?state=open&labels=&milestones= │ 过滤参数生效 │ PM 会话启动 │
├─────┼──────────────────────────────────┼────────────────────────────────────────────┼──────────────────┼──────────────────┤
│ 8 │ 读取 Issue 评论列表 │ GET /issues/{index}/comments │ 返回完整评论历史 │ PM 读取角色汇报 │
├─────┼──────────────────────────────────┼────────────────────────────────────────────┼──────────────────┼──────────────────┤
│ 9 │ Issue 依赖关系(添加/读取/删除) │ POST/GET/DELETE /issues/{index}/blocks │ 依赖链正确建立 │ P-003 依赖 P-002 │
├─────┼──────────────────────────────────┼────────────────────────────────────────────┼──────────────────┼──────────────────┤
│ 10 │ 创建/更新 Milestone │ POST/PATCH /milestones │ 新版本创建 │ 版本管理 │
└─────┴──────────────────────────────────┴────────────────────────────────────────────┴──────────────────┴──────────────────┘
P2 — 可选验证
┌─────┬────────────┬──────────────────────────────────────┬────────────────────────────┐
│ # │ 操作 │ 端点 │ 说明 │
├─────┼────────────┼──────────────────────────────────────┼────────────────────────────┤
│ 11 │ 时间追踪 │ POST /issues/{index}/stopwatch/start │ 替代 work-log.md,优先级低 │
├─────┼────────────┼──────────────────────────────────────┼────────────────────────────┤
│ 12 │ Issue 搜索 │ GET /repos/issues/search │ 跨仓库搜索,可能用不到 │
└─────┴────────────┴──────────────────────────────────────┴────────────────────────────┘
---
四、新流程设计
4.1 PM 会话启动追踪(核心变化)
旧:读 current.md → 读 reports/*.md → 汇报
新:
1. GET /milestones → 确认当前版本 Milestone
2. GET /issues?state=open&milestones={当前版本ID} → 获取当前版本所有活跃任务
3. 读取 Pipeline Tracker Meta Issue body → 了解流水线位置
4. 读取各 Issue 的 Labels → 理解每个任务状态/角色/质量门
5. 读取相关 Issue 评论 → 了解角色最新汇报
6. 读本地 reviews/ 最新审查记录(如有)
7. 向用户汇报 + 建议
4.2 角色汇报进度
旧:更新 docs/plans/reports/<role>-report.md
新:在对应 Issue 下评论
## 策划汇报 — 2026-06-06
**任务ID**: P-002a
**状态**: 已完成
**产出**: `docs/design/prototype/interaction-system-execution.md`
### 完成内容
- 创建独立执行案文件
- 移除技术越界内容
- 补充 4 个边界情况
### 待确认
- 交互取消行为链是否满足需求
4.3 质量门审查
旧:审查 → 写 reviews/<ID>-review.md → 更新 current.md
新:
1. PM 执行审查(仍读本地文件内容逐条检查)
2. 写本地 reviews/<ID>-review.md(详细记录保留本地)
3. 在对应 Issue 下发审查结论评论(摘要 + 文件链接)
4. 通过 API 更新 Issue Labels:移除 status:in-review,添加 status:blocked + gate:execution-fail(不通过时)
5. 更新 Pipeline Tracker Meta Issue body 中的进度表
4.4 任务归档
旧:current.md 中 [x] → 移到 archive/ 文件
新:用户确认完成 → PATCH /issues/{index} state=closed → 发完成评论(日期+产出链接)→ 不再需要 archive/ 文件
4.5 新任务创建
旧:在 current.md 中添加 - [ ] P-XXX ...
新:
1. POST /issues 创建 Issue(标题含任务ID、body 含模板信息、关联 Milestone、打上初始 Labels)
2. 如有依赖 → POST /issues/{index}/blocks 设置依赖关系
3. PM 更新 Pipeline Tracker Meta Issue body
---
五、实施步骤
步骤 1:API 验证脚本
编写 scripts/gitea-api.py 封装所有 API 操作,解决 UTF-8 编码问题。Agent 通过 python scripts/gitea-api.py <command> <args> 调用,而非裸 curl/python 内联。
验证所有 P0 + P1 接口。同时修复现有 3 个乱码 Issue(重建或更新内容)。
步骤 2:Label 体系初始化
通过脚本创建全部约 20 个 Labels。为现有 3 个 Issue 打上正确 Labels。
步骤 3:创建 Pipeline Tracker Meta Issue
将 current.md 中的流水线进度表迁移到一个 Meta Issue 的 body 中。
步骤 4:修改 PM Agent 的 system prompt
修改 .claude/agents/game-pm.md:
- 会话启动:从 Gitea API 读取进度(替代读 current.md + reports/)
- 质量门审查:审查后更新 Issue Labels + 评论
- 任务创建/归档:通过 API 创建/关闭 Issue
- 不再维护 current.md
步骤 5:修改策划/程序 Agent 的 system prompt
修改 .claude/agents/game-designer.md 和 .claude/agents/game-dev.md:
- 完成工作后在对应 Issue 下评论汇报(替代更新 reports/ 文件)
步骤 6:验证与稳定
用 PM Agent 按新流程完成一次完整工作会话,确认 API 调用链路稳定、数据准确。
---
六、风险与缓解
┌────────────────────────────┬──────┬──────────────────────────────────────────────────┐
│ 风险 │ 等级 │ 缓解措施 │
├────────────────────────────┼──────┼──────────────────────────────────────────────────┤
│ 网络不可用导致 PM 无法工作 │ 中 │ 接受此风险——Gitea 是自建服务,可用性由用户掌控 │
├────────────────────────────┼──────┼──────────────────────────────────────────────────┤
│ UTF-8 编码问题重现 │ 高 │ 封装 Python 脚本统一处理编码,ensure_ascii=False │
├────────────────────────────┼──────┼──────────────────────────────────────────────────┤
│ 看板列位置不可 API 操作 │ 低 │ 用 Labels 替代看板列概念;用户手动拖拽(频率低) │
├────────────────────────────┼──────┼──────────────────────────────────────────────────┤
│ Agent 调用 API 出错 │ 中 │ 封装脚本统一错误处理和重试;API 调用前本地记录 │
└────────────────────────────┴──────┴──────────────────────────────────────────────────┘
---
七、关键修改文件清单
┌──────────────────────────────────────┬───────────────────────────────────────────────────────────┐
│ 文件 │ 修改内容 │
├──────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ scripts/gitea-api.py │ 新建 — API 封装脚本,解决编码和错误处理 │
├──────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ .claude/agents/game-pm.md │ 大幅修改 — PM 的进度读取/质量门/任务管理全部改为 API 调用 │
├──────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ .claude/agents/game-designer.md │ 中等修改 — 汇报方式从写文件改为 Issue 评论 │
├──────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ .claude/agents/game-dev.md │ 中等修改 — 同上 │
├──────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ .claude/refs/development-workflow.md │ 小改 — 协作规则中更新进度汇报方式描述 │
├──────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ docs/plans/current.md │ 废弃 — 不再维护 │
├──────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ docs/plans/reports/*.md │ 废弃 — 不再维护 │
├──────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ docs/technical/gitea-api-guide.md │ 更新 — 补充新验证的 API 端点和脚本用法 │
└──────────────────────────────────────┴───────────────────────────────────────────────────────────┘
---
八、验证方式
1. API 验证:用 ai-pm Token 依次执行 P0 全部 6 项操作,确认中文显示正确、写权限可用
2. Label 创建:创建全部 Labels 并在 Web 面板确认显示正确
3. 端到端测试:PM Agent 用新流程完成一次完整的工作会话启动追踪,确认 API 调用链路正确
4. 质量门测试:PM 对一个 Issue 执行质量门审查,确认 Labels 更新和评论发布正确
+138
View File
@@ -0,0 +1,138 @@
---
name: "agent-memory"
description: "Agent 持久化记忆系统规范 — memory 类型、保存规则、索引维护、验证规则"
---
# Persistent Agent Memory
You have a persistent, file-based memory system. Your memory directory and MEMORY.md path are specified in your agent definition file — write to the memory directory directly with the Write tool (do not run mkdir or check for its existence).
You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.
If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.
## Types of memory
There are several discrete types of memory that you can store in your memory system:
<types>
<type>
<name>user</name>
<description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you are trying to accomplish together.</description>
<when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>
<how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>
<examples>
user: I'm a data scientist investigating what logging we have in place
assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]
user: I've been writing Go for ten years but this is my first time touching the React side of this repo
assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]
</examples>
</type>
<type>
<name>feedback</name>
<description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.</description>
<when_to_save>Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>
<how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>
<body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>
<examples>
user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed
assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]
user: stop summarizing what you just did at the end of every response, I can read the diff
assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]
user: yeah the single bundled PR was the right call here, splitting this one would've just been churn
assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]
</examples>
</type>
<type>
<name>project</name>
<description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.</description>
<when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>
<how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>
<body_structure>Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>
<examples>
user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch
assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]
user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements
assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]
</examples>
</type>
<type>
<name>reference</name>
<description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>
<when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>
<how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>
<examples>
user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs
assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]
user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone
assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]
</examples>
</type>
</types>
## What NOT to save in memory
- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.
- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
- Anything already documented in CLAUDE.md files.
- Ephemeral task details: in-progress work, temporary state, current conversation context.
These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping.
## How to save memories
Saving a memory is a two-step process:
**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:
```markdown
---
name: {{short-kebab-case-slug}}
description: {{one-line summary — used to decide relevance in future conversations, so be specific}}
metadata:
type: {{user, feedback, project, reference}}
---
{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines. Link related memories with [[their-name]].}}
```
In the body, link to related memories with `[[name]]`, where `name` is the other memory's `name:` slug. Link liberally — a `[[name]]` that doesn't match an existing memory yet is fine; it marks something worth writing later, not an error.
**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — each entry should be one line, under ~150 characters: `- [Title](file.md) — one-line hook`. It has no frontmatter. Never write memory content directly into `MEMORY.md`.
- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise
- Keep the name, description, and type fields in memory files up-to-date with the content
- Organize memory semantically by topic, not chronologically
- Update or remove memories that turn out to be wrong or outdated
- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
## When to access memories
- When memories seem relevant, or the user references prior-conversation work.
- You MUST access memory when the user explicitly asks you to check, recall, or remember.
- If the user says to *ignore* or *not use* memory: Do not apply remembered facts, cite, compare against, or mention memory content.
- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it.
## Before recommending from memory
A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:
- If the memory names a file path: check the file exists.
- If the memory names a function or flag: grep for it.
- If the user is about to act on your recommendation (not just asking about history), verify first.
"The memory says X exists" is not the same as "X exists now."
A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot.
## Memory and other forms of persistence
Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
- When to use or update tasks instead of memory: When you need to break your work into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
@@ -1,74 +0,0 @@
---
name: "floorplan-recognizer"
description: >
户型图识别工具。当需要"识别户型图"、"解析户型"、"提取户型信息"、"看户型有哪些房间/窗户"、
"floor plan recognition"、"户型图推导输入"时使用。用 qwen-vl-max 把链家等房产户型图解析成
结构化 JSON(房间/朝向/窗户形态),并生成「路径 B 文字钉死」片段,作为居民楼立面推导的可靠输入。
---
# 户型图识别工具 (Floorplan Recognizer)
> 户型图图片 → qwen-vl-max 解析 → 结构化 JSON + 「路径 B 文字钉死」片段
## 为什么需要它
AI 生图模型(GPT-Image-2)独立读户型图不可靠——会用"住宅常识"脑补(默认卧室大窗、阳台落地窗、漏房间),
导致路径 B「户型图推导」每张图都要**人工逐项校验窗户形态与比例**(见 `docs/art/美术风格指南.md` 路径 B 劣势)。
本工具先用专门的视觉大模型 qwen-vl-max 把户型图**解析成结构化清单**,把"人工逐项校验"降级为
"AI 出清单 + 人工快速核对"。
> 评测结论(详见 `ai-art-research/生成结果/户型图识别评测/评测对比.md`):qwen-vl-max 在房间完整性、
> 面积 OCR 精度、备注诚实度上领先 gpt-4o / qwen-vl-plus / gpt-4o-mini。
## 触发条件
- 用户说"识别/解析这张户型图"、"提取户型信息"、"看看这个户型有哪些房间/窗户"
- 路径 B 户型图推导前,需要可靠的「房间 + 窗户形态」清单作为"文字钉死"依据
- 用户提供了一张链家/房产户型图要求分析
## 工具位置
```
ai-art-research/tools/
floorplan_parse.py # 生产版(固定 qwen-vl-max,生成文字钉死片段)⭐ 本技能用这个
floorplan_eval.py # 评测版(可换模型对比)
```
依赖 `ai-art-research/.env` 的 `APIYI_API_KEY`(与 `image_gen.py` 共用)。
## 工作流
### 步骤 1:解析户型图
```bash
cd ai-art-research
python tools/floorplan_parse.py --image <户型图路径>
```
产出(存 `生成结果/户型图识别/`):
- `<时间戳>_apiyi_qwen-vl-max.json` — 结构化结果(房间/朝向/窗户/立面对应表)
- `<时间戳>_apiyi_qwen-vl-max.钉死.md` — 「路径 B 文字钉死」片段(可直接粘进生图 prompt)
- `.meta.json` — 调用元数据
### 步骤 2:人工快速核对
对照原图,重点核对「文字钉死」片段里的 ⚠️ 核对项(这些是评测发现的脑补重灾区):
- **卫生间朝向 / 是否暗卫**(模型间分歧最大)
- **阳台朝向**(皆南 vs 一南一北类分歧)
- **窗户类型 / 大小**(均为推测值)
房间数量与单间面积经评测高度可信(qwen-vl-max 与 gpt-4o 面积 OCR 完全一致),一般无需核对。
### 步骤 3:喂给路径 B 生图
把核对后的「文字钉死」片段贴进 `ai-art-research/prompts/居民楼-*-户型图推导-v*.txt` 的窗户形态段落,
作为户型图推导立面的"文字钉死"依据,再走 `image_gen.py --fuse` 生图。
## 注意事项
- 走 API易 的 **vision chat 接口(按 token 计费)**,与生图通道(codex 按订阅 / apiyi 按张)独立,互不影响
- 窗户类型/朝向/大小是模型**推测**,务必核对;房间结构与面积可信
- 若某张图效果不稳,可用 `floorplan_eval.py --image <图> -m <模型>` 换模型对比(参考评测对比.md)
- 链家等彩色标准化户型图适配良好;纯手绘/模糊扫描图效果会下降
- `ai-art-research/` 被项目 `.ignore` 屏蔽,操作需用显式路径(不进 Glob 检索)
+1 -8
View File
@@ -93,12 +93,10 @@ Desktop.ini
*.lfs
# ==============================
# Claude Code 工作树与运行时残留
# Claude Code 工作树
# ==============================
.claude/worktrees/
.worktrees/
# agent 运行时记忆(项目已移除 agent 记忆系统,见 commit 906e2aa)
.claude/agent-memory/
# ==============================
# Gitea 配置(含 Token,每台机器独立配置)
@@ -125,8 +123,3 @@ ai-art-research/.env
*.swo
*~
temp.txt
# ==============================
# AI 生图 — codex 运行残留(codex exec -o 输出,每次覆盖,不入库)
# ==============================
ai-art-research/.codex_lastmsg.txt
+5 -14
View File
@@ -5,10 +5,9 @@
## 项目概况
- **引擎**: Unreal Engine 5.7.4
- **引擎安装路径**: `E:\Program Files\Epic Games\UE_5.7`(**本项目必须使用此 5.7 引擎**;本机另有 5.6 在 `E:/MiceCola/work/UE_5_6`,切勿误用——5.6 的 UBT 不认项目 Target.cs 中的 5.7 专属枚举,会直接 RulesError)
- **美术风格**: 3D Lowpoly,温暖色调,怀旧氛围
- **核心玩法**: 模拟千禧年前后城郊结合区儿童的日常生活(探索、小游戏互动、收集、剧情叙事)
- **灵感来源**: NDS《放学后少年》自由探索 + PS《我的暑假》日常叙事节奏 +《深夏时光》玩法
- **灵感来源**: NDS《放学后少年》自由探索 + PS《我的暑假》日常叙事节奏
- **时代背景**: 2000年前后,城市边缘的城郊结合区(如南京迈皋桥),兼顾乡村情节
- **开发者**: starmen1265(单人独立开发)
@@ -17,6 +16,8 @@
## 项目管理
- **工具**: Gitea(Issues + Milestones)
- **PM 技能**: `gitea-pm`(任务创建/关闭、里程碑管理、Pipeline Tracker)
- **通用技能**: `gitea-collab`(任务查询、进度汇报、审查操作)
- **进度查询**: Pipeline Tracker(Meta Issue,仅记录当前阶段和阶段历史)
- **配置文件**: `.claude/skills/gitea-pm/gitea-config.json`(含连接信息、Token、角色映射、标签定义)
@@ -24,19 +25,9 @@
## Git 规范
详见 [docs/common/GIT-GUIDE.md](docs/common/GIT-GUIDE.md)。核心要点:
详见 [.claude/refs/GIT-GUIDE-LITE.md](.claude/refs/GIT-GUIDE-LITE.md)。核心要点:
- `main` 永远可玩可编译
- 分支: `develop/`
- 分支: `feature/` `fix/` `prototype/` `release/`
- 提交: `<type>(<scope>): <中文描述>`,subject ≤ 50 字符
- UE5: 蓝图与 C++ 分开提交;.uasset/.umap commit message 必须写清内容
---
## AI 生图
- **默认走 Codex 通道**(ChatGPT Plus 订阅额度,不按张计费),完整流程见 [`ai-art-research/codex生图工作规范.md`](ai-art-research/codex生图工作规范.md)。该通道为**协作模式**:`codex exec` 由**用户在终端执行**,Claude 负责组装 prompt 与捞图/命名/写 meta 收尾——Claude Code 的安全分类器会硬拦 `codex exec --dangerously-bypass-approvals-and-sandbox`(“Create Unsafe Agents”,不可配置放行),无法自动跑。
- **apiyi 兜底**(`tools/image_gen.py`,按张计费):撞 ~40–50 张/3h 限额 / 需真透明背景 / 需 Claude 全自动批量时使用;这条通道 Claude 可全自动跑。
- **必须复用现有资产**:`ai-art-research/prompts/*.txt`、`tools/image_gen.py` 的 `STYLE_SEED_EN` 风格种子、`生成结果/` 命名规则(`<时间戳>_gpt-image-2_<模式>.png` + 同名 `.meta.json`,新增 `backend: codex|apiyi` 字段)。
- codex 不在默认 PATH,执行路径:`~/.codex/packages/standalone/current/bin/codex.exe`(命令模板:`codex exec --enable image_generation --dangerously-bypass-approvals-and-sandbox -C ai-art-research -o <lastmsg> "<prompt>"`,prompt 内须明示“用内置 image_gen 工具、不要写脚本/不要用 API key”)。
- ⚠️ `ai-art-research/` 被项目 `.ignore` 屏蔽,不进 ripgrep/Glob 检索;操作需用显式路径。
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-14
View File
@@ -37,20 +37,6 @@ marble_large,大弹珠,2.5,20,80,25
}
```
## 弹珠小游戏配置(v0.2)
| 文件 | 格式 | 用途 | 关键单位 |
|------|------|------|----------|
| `marble-types.csv` | CSV | 弹珠属性(玻璃/陶瓷/铁 + 目标弹珠 + 兜底弹珠) | radius_m(米)、mass(相对值)、frictionAdd/restitution(无量纲 0-1) |
| `marble-venues.csv` | CSV | 场地配置(水泥地/泥土地) | friction(无量纲) |
| `marble-npc.csv` | CSV | NPC 对手配置(死党B/小霸王) | angleError_deg(度)、powerError/basePower(%)、distComp(%/m) |
| `marble-config.json` | JSON | 画圈弹规则、操作、物理、库存、时序等键值参数 | 见各字段 unit |
> CSV 列名后缀表示单位(`_m` 米、`_deg` 度、`_pct` 百分比、`_pctPerM` 每米百分比、`_mps` 米/秒、`_hz` 赫兹)。JSON 每个参数为 `{value, min, max, unit, note}` 对象,value 为建议值(程序导入用),min/max 为可调范围。
> 来源:`docs/design/弹珠小游戏-执行案.md` v0.2.1。摩擦组合规则:最终摩擦 = 场地摩擦 + 弹珠摩擦加成。
---
## 工作流
1. 策划 Agent 创建/更新配置文件
-41
View File
@@ -1,41 +0,0 @@
{
"_meta": {
"doc": "弹珠小游戏配置(v0.2)",
"source": "弹珠小游戏-执行案.md v0.2.1",
"field_note": "value=建议值(程序导入用),min/max=可调范围(调参参考),unit=单位,note=说明"
},
"circleRule": {
"circleRadius_m": { "value": 1.0, "min": 0.8, "max": 1.5, "unit": "m", "note": "弹出判定圈半径" },
"targetCount": { "value": 5, "min": 3, "max": 8, "unit": "颗", "note": "圈中央目标弹珠数" },
"popTolerance_m": { "value": 0.01, "min": 0.005, "max": 0.02, "unit": "m", "note": "弹出判定容差,统一带容差阈值" },
"boundaryExpand_m": { "value": 0.5, "min": 0.3, "max": 1.0, "unit": "m", "note": "场地隐形反弹边界外扩" },
"betMarbleCount": { "value": 1, "min": 1, "max": 3, "unit": "颗", "note": "胜负流转的对赌弹珠数" },
"turnOrder": { "value": "player_first", "note": "v0.2 固定玩家先手" }
},
"operation": {
"maxDragScreenRatio": { "value": 0.15, "min": 0.08, "max": 0.25, "unit": "屏幕高度占比", "note": "力度100%对应的拖拽距离,相对屏幕高度" },
"minValidPower": { "value": 0.10, "min": 0.05, "max": 0.20, "unit": "比例", "note": "低于此松开视为取消" },
"predictionLineLength_m": { "value": 3.0, "min": 2.0, "max": 5.0, "unit": "m", "note": "轨迹预测线最大显示长度" },
"maxLaunchSpeed_mps": { "value": 8.0, "min": 5.0, "max": 12.0, "unit": "m/s", "note": "100%力度弹射初速度,对所有弹珠相同" },
"shooterHighlightFreq_hz": { "value": 1.0, "min": 0.5, "max": 2.0, "unit": "Hz", "note": "PlayerAim 射手弹珠脉冲发光频率" }
},
"physics": {
"stopSpeedThreshold_mps": { "value": 0.05, "min": 0.02, "max": 0.10, "unit": "m/s", "note": "判定弹珠停止的速度阈值" },
"stableTime_s": { "value": 1.0, "min": 0.5, "max": 2.0, "unit": "秒", "note": "低于阈值持续至此判定停止" },
"simulateTimeout_s": { "value": 15, "min": 10, "max": 30, "unit": "秒", "note": "物理结算超时强制停止" },
"boundaryRestitution": { "value": 0.5, "min": 0.3, "max": 0.7, "unit": "系数", "note": "弹珠-场地边界反弹系数" },
"frictionCombineRule": { "value": "venue_friction + marble_frictionAdd", "note": "最终摩擦=场地摩擦+弹珠摩擦加成" }
},
"inventory": {
"initialCount": { "value": 5, "min": 3, "max": 10, "unit": "颗", "note": "玩家初始对战库存" },
"guaranteeFloor": { "value": 3, "min": 2, "max": 5, "unit": "颗", "note": "保底下限,玩家败时库存不低于此" }
},
"timing": {
"readyDuration_s": { "value": 0.8, "min": 0.5, "max": 1.2, "unit": "秒", "note": "进入就位表现(原位转向+蹲下)" },
"enterCameraDuration_s": { "value": 0.6, "min": 0.4, "max": 1.0, "unit": "秒", "note": "第三人称→斜俯视相机切换" },
"exitCameraDuration_s": { "value": 0.6, "min": 0.4, "max": 1.0, "unit": "秒", "note": "斜俯视→第三人称相机切换" },
"standupDuration_s": { "value": 0.8, "min": 0.5, "max": 1.2, "unit": "秒", "note": "退出站起表现" },
"resultDisplayDuration_s": { "value": 2.0, "min": 1.5, "max": 3.0, "unit": "秒", "note": "胜负结果显示时长" },
"npcThinkDelay_s": { "value": 1.0, "min": 0.5, "max": 2.0, "unit": "秒", "note": "NPC 思考延迟" }
}
}
-3
View File
@@ -1,3 +0,0 @@
id,displayName,angleError_deg,angleErrorMin_deg,angleErrorMax_deg,powerError_pct,powerErrorMin_pct,powerErrorMax_pct,basePower_pct,basePowerMin_pct,basePowerMax_pct,distComp_pctPerM,distCompMin_pctPerM,distCompMax_pctPerM,strategyNote,marbleType
friend_b,死党B(默认),3,1,10,10,5,25,50,30,70,15,10,25,瞄准最近目标弹珠,glass
bully,小霸王(备选),8,1,10,20,5,25,60,30,70,15,10,25,瞄准距圈边缘最近易弹出目标,iron
1 id displayName angleError_deg angleErrorMin_deg angleErrorMax_deg powerError_pct powerErrorMin_pct powerErrorMax_pct basePower_pct basePowerMin_pct basePowerMax_pct distComp_pctPerM distCompMin_pctPerM distCompMax_pctPerM strategyNote marbleType
2 friend_b 死党B(默认) 3 1 10 10 5 25 50 30 70 15 10 25 瞄准最近目标弹珠 glass
3 bully 小霸王(备选) 8 1 10 20 5 25 60 30 70 15 10 25 瞄准距圈边缘最近易弹出目标 iron
-6
View File
@@ -1,6 +0,0 @@
id,displayName,radius_m,radiusMin_m,radiusMax_m,mass,massMin,massMax,frictionAdd,frictionAddMin,frictionAddMax,restitution,restitutionMin,restitutionMax,strategyNote
glass,玻璃弹珠(标准),0.015,0.01,0.02,1.0,0.8,1.2,0.0,0.0,0.2,0.5,0.4,0.7,滚得远走线精准远距主力
ceramic,陶瓷弹珠(粗糙),0.015,0.01,0.02,1.0,0.8,1.2,0.3,0.2,0.5,0.8,0.7,0.9,滚得近碰撞利落近距强撞
iron,铁弹珠(重),0.012,0.008,0.015,2.5,2.0,3.0,0.1,0.0,0.3,0.6,0.5,0.8,质量大撞散目标警惕贪力反噬
target,目标弹珠(等同玻璃),0.015,0.01,0.02,1.0,0.8,1.2,0.0,0.0,0.2,0.5,0.4,0.7,圈中央中立目标弹珠
fallback,系统基础弹珠(等同玻璃),0.015,0.01,0.02,1.0,0.8,1.2,0.0,0.0,0.2,0.5,0.4,0.7,库存异常兜底用
1 id displayName radius_m radiusMin_m radiusMax_m mass massMin massMax frictionAdd frictionAddMin frictionAddMax restitution restitutionMin restitutionMax strategyNote
2 glass 玻璃弹珠(标准) 0.015 0.01 0.02 1.0 0.8 1.2 0.0 0.0 0.2 0.5 0.4 0.7 滚得远走线精准远距主力
3 ceramic 陶瓷弹珠(粗糙) 0.015 0.01 0.02 1.0 0.8 1.2 0.3 0.2 0.5 0.8 0.7 0.9 滚得近碰撞利落近距强撞
4 iron 铁弹珠(重) 0.012 0.008 0.015 2.5 2.0 3.0 0.1 0.0 0.3 0.6 0.5 0.8 质量大撞散目标警惕贪力反噬
5 target 目标弹珠(等同玻璃) 0.015 0.01 0.02 1.0 0.8 1.2 0.0 0.0 0.2 0.5 0.4 0.7 圈中央中立目标弹珠
6 fallback 系统基础弹珠(等同玻璃) 0.015 0.01 0.02 1.0 0.8 1.2 0.0 0.0 0.2 0.5 0.4 0.7 库存异常兜底用
-3
View File
@@ -1,3 +0,0 @@
id,displayName,friction,frictionMin,frictionMax,behaviorNote
concrete,水泥地,0.3,0.2,0.5,摩擦低滚动均匀距离远走线可预测适合精准操作
dirt,泥土地,0.6,0.4,0.8,摩擦高减速快滚动距离明显短需更大力度到达圈心
1 id displayName friction frictionMin frictionMax behaviorNote
2 concrete 水泥地 0.3 0.2 0.5 摩擦低滚动均匀距离远走线可预测适合精准操作
3 dirt 泥土地 0.6 0.4 0.8 摩擦高减速快滚动距离明显短需更大力度到达圈心
-1
View File
@@ -26,7 +26,6 @@ public class ChildHood : ModuleRules
PublicIncludePaths.AddRange(new string[] {
"ChildHood",
"ChildHood/Interaction",
"ChildHood/Collection",
"ChildHood/Variant_Platforming",
"ChildHood/Variant_Platforming/Animation",
"ChildHood/Variant_Combat",
+19 -90
View File
@@ -1,8 +1,3 @@
// ChildHood Project - 玩家角色 实现(v1.1 Effect/Action 架构适配)
// 变更:通过 ASystemDriver 访问 FInteractionManager + FEffectManager
// 新增 BeginPlay 初始化 PromptComponent
// 删除对旧 UInteractionManager 的依赖
// Copyright Epic Games, Inc. All Rights Reserved.
#include "ChildHoodCharacter.h"
@@ -16,13 +11,7 @@
#include "EnhancedInputSubsystems.h"
#include "InputActionValue.h"
#include "ChildHood.h"
#include "Interaction/InteractionPromptComponent.h"
#include "Collection/CollectionHUDComponent.h"
#include "SystemDriver.h"
#include "Interaction/InteractionManager.h"
#include "Interaction/EffectManager.h"
#include "Interaction/InteractableComponent.h"
#include "Interaction/InteractionTypes.h"
AChildHoodCharacter::AChildHoodCharacter()
{
@@ -66,34 +55,8 @@ AChildHoodCharacter::AChildHoodCharacter()
FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName);
FollowCamera->bUsePawnControlRotation = false;
// 创建交互提示组件(替代旧 InteractionManager)
PromptComponent = CreateDefaultSubobject<UInteractionPromptComponent>(TEXT("PromptComponent"));
// 创建收集系统 HUD 组件(表现层,挂在角色上;Tick 兜底查找 Driver)
CollectionHUDComponent = CreateDefaultSubobject<UCollectionHUDComponent>(TEXT("CollectionHUDComponent"));
}
void AChildHoodCharacter::BeginPlay()
{
Super::BeginPlay();
// 初始化 PromptComponent — 将其与 ASystemDriver 关联
if (PromptComponent)
{
if (ASystemDriver* Driver = ASystemDriver::Get(this))
{
PromptComponent->Initialize(Driver);
}
}
// 初始化收集系统 HUD 组件(Driver 弱引用;Tick 会兜底查找,规避 BeginPlay 时序竞争)
if (CollectionHUDComponent)
{
if (ASystemDriver* Driver = ASystemDriver::Get(this))
{
CollectionHUDComponent->Initialize(Driver);
}
}
// 创建交互管理器组件
InteractionManager = CreateDefaultSubobject<UInteractionManager>(TEXT("InteractionManager"));
}
void AChildHoodCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
@@ -108,7 +71,7 @@ void AChildHoodCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInput
EnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this, &AChildHoodCharacter::Look);
EnhancedInputComponent->BindAction(MouseLookAction, ETriggerEvent::Triggered, this, &AChildHoodCharacter::Look);
// Interaction — delegates to ASystemDriver
// Interaction — delegates to InteractionManager
if (InteractAction)
{
EnhancedInputComponent->BindAction(InteractAction, ETriggerEvent::Started, this, &AChildHoodCharacter::OnInteract);
@@ -148,59 +111,30 @@ void AChildHoodCharacter::Look(const FInputActionValue& Value)
void AChildHoodCharacter::OnInteract(const FInputActionValue& Value)
{
ASystemDriver* Driver = ASystemDriver::Get(this);
if (!Driver) return;
auto& Mgr = Driver->GetInteractionManager();
auto& EFM = Driver->GetEffectManager();
// E 键三重功能:
// 1. Free 状态:发起交互
// 2. WaitForInput 暂停态:恢复 Effect(观察/对话类按 E 继续)
// 3. 可取消 Effect 执行中:取消 Effect
if (Mgr.GetState() == EInteractionState::Free)
if (UInteractionManager* IM = GetInteractionManager())
{
// 发起交互
auto* Selected = Mgr.BeginInteract();
if (!Selected) return;
SetInteracting(true);
FEffectContext Ctx;
Ctx.Instigator = this;
Ctx.TargetComp = Selected;
Ctx.TargetActor = Selected->GetOwner();
EFM.PlayEffect(Selected->GetInteractableData().InteractEffect, Ctx);
}
else
{
// Executing 状态 → 尝试取消/恢复
EFM.CancelEffect();
// E 键双重功能:Free 发起交互,Active 结束交互
IM->BeginInteract();
IM->CancelInteract();
}
}
void AChildHoodCharacter::OnCancel(const FInputActionValue& Value)
{
ASystemDriver* Driver = ASystemDriver::Get(this);
if (!Driver) return;
auto& EFM = Driver->GetEffectManager();
EFM.CancelEffect();
if (UInteractionManager* IM = GetInteractionManager())
{
IM->CancelInteract();
}
}
void AChildHoodCharacter::OnSwitchInteract(const FInputActionValue& Value)
{
ASystemDriver* Driver = ASystemDriver::Get(this);
if (!Driver) return;
auto& Mgr = Driver->GetInteractionManager();
// 交互中不可切换
if (Mgr.GetState() != EInteractionState::Free) return;
float DirectionValue = Value.Get<float>();
int32 Direction = (DirectionValue >= 0.f) ? 1 : -1;
Mgr.SwitchTarget(Direction);
if (UInteractionManager* IM = GetInteractionManager())
{
float DirectionValue = Value.Get<float>();
int32 Direction = (DirectionValue >= 0.f) ? 1 : -1;
IM->SwitchTarget(Direction);
}
}
void AChildHoodCharacter::DoMove(float Right, float Forward)
@@ -243,14 +177,9 @@ void AChildHoodCharacter::DoLook(float Yaw, float Pitch)
// 交互系统方法
// ============================================================================
UInteractionPromptComponent* AChildHoodCharacter::GetPromptComponent() const
UInteractionManager* AChildHoodCharacter::GetInteractionManager() const
{
return PromptComponent;
}
UCollectionHUDComponent* AChildHoodCharacter::GetCollectionHUDComponent() const
{
return CollectionHUDComponent;
return InteractionManager;
}
void AChildHoodCharacter::SetInteracting(bool bValue)
+10 -28
View File
@@ -1,7 +1,3 @@
// ChildHood Project - 玩家角色(v1.1 Effect/Action 架构适配)
// 变更:移除 UInteractionManager,改用 UInteractionPromptComponent
// 交互操作通过 ASystemDriver 访问 FInteractionManager + FEffectManager
// Copyright Epic Games, Inc. All Rights Reserved.
#pragma once
@@ -14,9 +10,7 @@
class USpringArmComponent;
class UCameraComponent;
class UInputAction;
class UInteractionPromptComponent;
class UCollectionHUDComponent;
class ASystemDriver;
class UInteractionManager;
struct FInputActionValue;
DECLARE_LOG_CATEGORY_EXTERN(LogTemplateCharacter, Log, All);
@@ -24,10 +18,6 @@ DECLARE_LOG_CATEGORY_EXTERN(LogTemplateCharacter, Log, All);
/**
* A simple player-controllable third person character
* Implements a controllable orbiting camera
*
* v1.1 架构变更:
* - 交互操作通过 ASystemDriver 全局单例访问 FInteractionManager / FEffectManager
* - 身上挂载 UInteractionPromptComponent 负责 Free 状态下的 Prompt UI 追踪
*/
UCLASS(abstract)
class AChildHoodCharacter : public ACharacter
@@ -68,31 +58,21 @@ protected:
UPROPERTY(EditAnywhere, Category="Input")
UInputAction* SwitchInteractAction;
/** 交互提示 UI 组件 — Free 状态下追踪选中交互物并显示 Prompt */
/** 交互管理器组件 */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Interaction", meta = (AllowPrivateAccess = "true"))
UInteractionPromptComponent* PromptComponent;
/** 收集系统 HUD 组件 — 表现层,只读 CollectionManager 缓存驱动收集 UI */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Collection", meta = (AllowPrivateAccess = "true"))
UCollectionHUDComponent* CollectionHUDComponent;
UInteractionManager* InteractionManager;
/** Whether the character is currently in an interaction */
bool bIsInteracting = false;
virtual void BeginPlay() override;
public:
/** Constructor */
AChildHoodCharacter();
/** 获取交互提示组件 */
/** 获取交互管理器组件 */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Interaction")
UInteractionPromptComponent* GetPromptComponent() const;
/** 获取收集系统 HUD 组件 */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Collection")
UCollectionHUDComponent* GetCollectionHUDComponent() const;
UInteractionManager* GetInteractionManager() const;
/** Set interaction state — called by interaction system */
UFUNCTION(BlueprintCallable, Category = "Interaction")
@@ -111,19 +91,21 @@ protected:
/** Initialize input action bindings */
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
protected:
/** Called for movement input */
void Move(const FInputActionValue& Value);
/** Called for looking input */
void Look(const FInputActionValue& Value);
/** Called for interact input (E) — delegates to ASystemDriver */
/** Called for interact input (E) — delegates to InteractionManager */
void OnInteract(const FInputActionValue& Value);
/** Called for cancel input (ESC) — delegates to ASystemDriver */
/** Called for cancel input (ESC) — delegates to InteractionManager */
void OnCancel(const FInputActionValue& Value);
/** Called for switch interact target input (Tab/Wheel) — delegates to ASystemDriver */
/** Called for switch interact target input (Tab/Wheel) — delegates to InteractionManager */
void OnSwitchInteract(const FInputActionValue& Value);
public:
+28 -3
View File
@@ -1,5 +1,30 @@
// ChildHood Project - GameInstance(v1.1 清理版)
// 持久数据已迁移至 UPersistenceSubsystem(UGameInstanceSubsystem)
// ChildHood Project - 跨关卡持久数据持有者 实现
#include "ChildHoodGameInstance.h"
// v0.1: 空壳实现,持久数据由 UPersistenceSubsystem 管理
bool UChildHoodGameInstance::IsItemCollected(FName ItemId) const
{
return CollectedItemIDs.Contains(ItemId);
}
void UChildHoodGameInstance::MarkItemCollected(FName ItemId)
{
CollectedItemIDs.Add(ItemId);
}
bool UChildHoodGameInstance::IsDoorOpen(FName DoorId) const
{
const bool* Found = DoorStates.Find(DoorId);
return Found ? *Found : false;
}
void UChildHoodGameInstance::SetDoorOpen(FName DoorId, bool bIsOpen)
{
DoorStates.Add(DoorId, bIsOpen);
}
void UChildHoodGameInstance::ResetAll()
{
CollectedItemIDs.Empty();
DoorStates.Empty();
}
+38 -6
View File
@@ -1,5 +1,5 @@
// ChildHood Project - GameInstance(v1.1 清理版)
// 持久数据已迁移至 UPersistenceSubsystem(UGameInstanceSubsystem)
// ChildHood Project - 跨关卡持久数据持有者
// 对应技术方案: docs/technical/交互系统-技术方案.md 第12节(跨场景持久化设计)
#pragma once
@@ -10,8 +10,11 @@
/**
* UChildHoodGameInstance
*
* v0.1: 空壳,持久化数据由 UPersistenceSubsystem 管理。
* 后续可添加:全局配置、存档管理、音频设置等 GameInstance 级功能。
* 跨关卡持久数据持有者。UE5 的 OpenLevel() 会销毁整个旧 World(包括
* Character 及其所有 UActorComponent),因此需要将持久数据与运行时行为分离。
*
* v0.1 为单场景原型,此 GameInstance 以空数据初始化。
* 后续多场景版本只需在 Project Settings 中将 GameInstance Class 设为此类即可。
*/
UCLASS()
class UChildHoodGameInstance : public UGameInstance
@@ -19,6 +22,35 @@ class UChildHoodGameInstance : public UGameInstance
GENERATED_BODY()
public:
// v0.1: 空壳,持久化数据由 UPersistenceSubsystem 管理
// 后续可添加:全局配置、存档管理、音频设置等 GameInstance 级功能
// === 物品消耗状态 ===
/** 查询指定物品是否已被收集(跨场景持久) */
bool IsItemCollected(FName ItemId) const;
/** 标记指定物品为已收集(写入持久层) */
void MarkItemCollected(FName ItemId);
// === 门状态 ===
/** 查询指定门的开关状态(跨场景持久),未记录的门默认为关闭 */
bool IsDoorOpen(FName DoorId) const;
/** 设置指定门的开关状态(写入持久层) */
void SetDoorOpen(FName DoorId, bool bIsOpen);
// === 重置 ===
/** 重置所有持久数据(新游戏时调用) */
void ResetAll();
private:
/** 已收集物品ID集合 */
UPROPERTY()
TSet<FName> CollectedItemIDs;
/** 门开关状态映射(true=打开,false=关闭) */
UPROPERTY()
TMap<FName, bool> DoorStates;
};
-18
View File
@@ -1,26 +1,8 @@
// Copyright Epic Games, Inc. All Rights Reserved.
#include "ChildHoodGameMode.h"
#include "SystemDriver.h"
AChildHoodGameMode::AChildHoodGameMode()
{
// stub
}
void AChildHoodGameMode::BeginPlay()
{
Super::BeginPlay();
// 自动 Spawn ASystemDriver(全局交互系统驱动器)
// 如果关卡中已手动放置则跳过,避免重复
if (UWorld* World = GetWorld())
{
if (!ASystemDriver::Get(this))
{
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
World->SpawnActor<ASystemDriver>(ASystemDriver::StaticClass(), FTransform::Identity, SpawnParams);
}
}
}
+4 -3
View File
@@ -15,9 +15,10 @@ class AChildHoodGameMode : public AGameModeBase
GENERATED_BODY()
public:
/** Constructor */
AChildHoodGameMode();
virtual void BeginPlay() override;
};
@@ -1,71 +0,0 @@
// ChildHood Project - 收集完成提示 UI 实现
// 三段时序:淡入 → 停留 → 淡出 → 移除(自管理,HUDComponent 按需创建后无需追踪)
#include "Collection/CollectionCompleteWidget.h"
#include "Components/TextBlock.h"
#include "Engine/World.h"
#include "TimerManager.h"
void UCollectionCompleteWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
}
void UCollectionCompleteWidget::NativeDestruct()
{
// 清理计时器:StayTimer/FadeOutTimer 的 lambda 捕获裸 this,Widget 销毁后 fire 会野指针
// (与 CollectionHUDComponent::EndPlay 同类卫生写法,防关卡切换/退 PIE 时的悬挂 timer)
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(StayTimerHandle);
World->GetTimerManager().ClearTimer(FadeOutTimerHandle);
}
Super::NativeDestruct();
}
void UCollectionCompleteWidget::ShowComplete(const FString& Text, float DisplayDuration, float InFadeOutDuration)
{
CachedFadeOutDuration = InFadeOutDuration;
if (CompleteTextBlock)
{
CompleteTextBlock->SetText(FText::FromString(Text));
}
// 淡入
if (FadeInAnim)
{
PlayAnimation(FadeInAnim);
}
// 停留时长后触发淡出
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(StayTimerHandle, [this]()
{
StartFadeOut();
}, DisplayDuration, false);
}
}
void UCollectionCompleteWidget::StartFadeOut()
{
if (FadeOutAnim)
{
PlayAnimation(FadeOutAnim);
}
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(FadeOutTimerHandle, [this]()
{
OnFadeOutComplete();
}, CachedFadeOutDuration, false);
}
}
void UCollectionCompleteWidget::OnFadeOutComplete()
{
RemoveFromParent();
}
@@ -1,72 +0,0 @@
// ChildHood Project - 收集完成提示 UI 基类
// 对应技术方案: docs/technical/收集系统-技术方案.md 第3节(UCollectionCompleteWidget)+ 第7节
// 职责:全部收集完成时的屏幕中央提示(淡入→停留→淡出→移除)
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "CollectionCompleteWidget.generated.h"
class UTextBlock;
/**
* UCollectionCompleteWidget — 全部收集完成中心提示(执行案第四节「全部收集完成表现」)。
*
* 屏幕正中央弹出「杂物全部收集完成!」白色大字,显示 2.0s 后淡出移除。
* 三段时序:淡入 → 停留(DisplayDuration) → 淡出 → RemoveFromParent。
*
* 绑定要求(蓝图 WBP_CollectionComplete 继承本类):
* - TextBlock 名 "CompleteTextBlock" — 完成文字
* - WidgetAnimation 名 "FadeInAnim" — 淡入动画
* - WidgetAnimation 名 "FadeOutAnim" — 淡出动画
*
* 布局要求:Anchors Center,白色大字,屏幕正中。
*/
UCLASS(Abstract, Blueprintable)
class UCollectionCompleteWidget : public UUserWidget
{
GENERATED_BODY()
public:
/** 显示完成提示:设置文字,启动淡入→停留→淡出时序 */
UFUNCTION(BlueprintCallable, Category = "Collection|Complete")
void ShowComplete(const FString& Text, float DisplayDuration, float InFadeOutDuration);
protected:
/** 完成文字 TextBlock,蓝图子类必须有同名 TextBlock */
UPROPERTY(meta = (BindWidget))
UTextBlock* CompleteTextBlock;
/** 淡入动画,蓝图子类必须有同名 WidgetAnimation */
UPROPERTY(meta = (BindWidgetAnim), Transient)
UWidgetAnimation* FadeInAnim;
/** 淡出动画,蓝图子类必须有同名 WidgetAnimation */
UPROPERTY(meta = (BindWidgetAnim), Transient)
UWidgetAnimation* FadeOutAnim;
/** 停留计时器(淡入结束后启动,到时触发淡出)*/
FTimerHandle StayTimerHandle;
/** 淡出计时器(淡出开始后启动,到时移除 Widget)*/
FTimerHandle FadeOutTimerHandle;
/** 淡出时长(缓存)*/
float CachedFadeOutDuration = 0.5f;
virtual void NativeOnInitialized() override;
/** Widget 销毁时清理计时器:lambda 捕获裸 this,销毁后 fire 会野指针 */
virtual void NativeDestruct() override;
private:
/** 停留结束 → 播放淡出动画 */
void StartFadeOut();
/** 淡出时长到 → RemoveFromParent */
void OnFadeOutComplete();
};
@@ -1,265 +0,0 @@
// ChildHood Project - 收集系统表现层 实现
// 对应技术方案: docs/technical/收集系统-技术方案.md 第5节(数据结构)+ 第6节(行为规则实现)+ 第9节
#include "Collection/CollectionHUDComponent.h"
#include "SystemDriver.h"
#include "Collection/CollectionManager.h"
#include "Collection/CollectionNotifWidget.h"
#include "Collection/CollectionProgressWidget.h"
#include "Collection/CollectionCompleteWidget.h"
#include "Engine/World.h"
#include "TimerManager.h"
#include "GameFramework/PlayerController.h"
#include "Blueprint/UserWidget.h"
#include "ChildHood.h"
// ============================================================================
// 构造 / 生命周期
// ============================================================================
UCollectionHUDComponent::UCollectionHUDComponent()
{
// 需要每帧 Tick 读取 Manager 缓存(开销极低:几个 int32 比较)
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.bStartWithTickEnabled = true;
}
void UCollectionHUDComponent::Initialize(ASystemDriver* InDriver)
{
DriverPtr = InDriver;
}
void UCollectionHUDComponent::BeginPlay()
{
Super::BeginPlay();
}
void UCollectionHUDComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// 清理 timer:NotifDelayTimer 的 lambda 与 CompleteTimer 的成员函数绑定均捕获裸 this,
// 组件销毁后 fire 会野指针。显式 ClearTimer 防御(UE5 Widget/Timer 卫生写法,与项目其他 Character 一致)。
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(NotifDelayTimerHandle);
World->GetTimerManager().ClearTimer(CompleteTimerHandle);
}
// 移除挂载的通知 Widget
for (UCollectionNotifWidget* Notif : ActiveNotifications)
{
if (Notif)
{
Notif->RemoveFromParent();
}
}
ActiveNotifications.Empty();
// 移除常驻进度 Widget
if (ProgressWidget)
{
ProgressWidget->RemoveFromParent();
ProgressWidget = nullptr;
}
Super::EndPlay(EndPlayReason);
}
// ============================================================================
// Tick — 只读 Manager 缓存,检测变化驱动 UI
// ============================================================================
void UCollectionHUDComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// 获取 Driver(优先缓存,失效则全局查找并更新缓存)
ASystemDriver* Driver = DriverPtr.Get();
if (!Driver)
{
Driver = ASystemDriver::Get(GetWorld());
if (Driver)
{
DriverPtr = Driver;
}
}
if (!Driver) return;
// 懒创建常驻进度 Widget(首次有效 Tick 时,确保 World 就绪 + 规避 Driver BeginPlay 时序竞争)
if (!ProgressWidget && ProgressWidgetClass)
{
CreateProgressWidget(Driver);
}
if (!ProgressWidget) return;
const FCollectionManager& CollMgr = Driver->GetCollectionManager();
const FCollectionCachedInfo& Cache = CollMgr.GetCachedInfo();
// === 检测进度变化(步骤4:弹收集通知)===
// LastSeenProgress < 0 表示首次 Tick,由 CreateProgressWidget 初始化为当前进度,不触发通知
if (Cache.Progress != LastSeenProgress && LastSeenProgress >= 0)
{
// 更新进度 UI(同步)+ 弹性动画
UpdateProgress(Cache.Progress, Cache.TotalCount);
// 弹收集通知(延迟 NotifDelay 后创建,避免与拾取通知视觉重叠 — 执行案第二节)
const float NotifDelay = CollMgr.GetConfig().NotifDelay;
const FString NotifText = Cache.PendingNotificationText;
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(NotifDelayTimerHandle, [this, NotifText]()
{
ShowNotification(NotifText);
}, NotifDelay, false);
}
}
LastSeenProgress = Cache.Progress;
// === 检测完成(步骤5:完成判定 → 完成表现)===
if (Cache.bJustCompleted && !bCompletionShown)
{
TriggerCompletionSequence(Driver);
bCompletionShown = true;
}
}
// ============================================================================
// 进度 Widget
// ============================================================================
void UCollectionHUDComponent::CreateProgressWidget(ASystemDriver* Driver)
{
UWorld* World = GetWorld();
if (!World) return;
APlayerController* PC = World->GetFirstPlayerController();
if (!PC) return;
ProgressWidget = CreateWidget<UCollectionProgressWidget>(PC, ProgressWidgetClass);
if (!ProgressWidget) return;
// 常驻显示(ZOrder 5,低于通知/完成提示)
ProgressWidget->AddToViewport(5);
// 初始显示已恢复的进度(执行案第三节:游戏开始后始终可见)
const FCollectionCachedInfo& Cache = Driver->GetCollectionManager().GetCachedInfo();
ProgressWidget->UpdateProgress(Cache.Progress, Cache.TotalCount);
if (Cache.bIsComplete)
{
ProgressWidget->SetCompleteStyle();
}
// 记录初始进度,避免首次 Tick 误触发通知
LastSeenProgress = Cache.Progress;
UE_LOG(LogChildHood, Log, TEXT("CollectionHUDComponent: 进度 Widget 创建完成,初始进度 %d/%d"),
Cache.Progress, Cache.TotalCount);
}
// ============================================================================
// 收集通知
// ============================================================================
void UCollectionHUDComponent::ShowNotification(const FString& Text)
{
ASystemDriver* Driver = DriverPtr.Get();
if (!Driver) return;
const FCollectionConfig& Config = Driver->GetCollectionManager().GetConfig();
// 同屏最大数量限制(执行案第二节)
if (ActiveNotifications.Num() >= Config.MaxSimultaneousNotifs)
{
UE_LOG(LogChildHood, Verbose, TEXT("CollectionHUDComponent: 通知已达上限 %d,跳过"), Config.MaxSimultaneousNotifs);
return;
}
UWorld* World = GetWorld();
if (!NotifWidgetClass || !World) return;
APlayerController* PC = World->GetFirstPlayerController();
if (!PC) return;
UCollectionNotifWidget* NotifWidget = CreateWidget<UCollectionNotifWidget>(PC, NotifWidgetClass);
if (!NotifWidget) return;
// 绑定完成回调(淡出移除时清理 ActiveNotifications)
NotifWidget->OnFinished.BindUFunction(this, FName("OnNotificationRemoved"));
NotifWidget->InitializeNotif(Text, Config.NotifTextColor,
Config.NotifFadeInDuration, Config.NotifStayDuration, Config.NotifFadeOutDuration);
NotifWidget->AddToViewport(10);
ActiveNotifications.Add(NotifWidget);
}
// ============================================================================
// 进度更新
// ============================================================================
void UCollectionHUDComponent::UpdateProgress(int32 Current, int32 Total)
{
if (ProgressWidget)
{
ProgressWidget->UpdateProgress(Current, Total);
// 数字弹性动画(执行案第三节)
ProgressWidget->PlayBounceAnimation();
}
}
// ============================================================================
// 完成序列
// ============================================================================
void UCollectionHUDComponent::TriggerCompletionSequence(ASystemDriver* Driver)
{
if (!ProgressWidget) return;
const FCollectionConfig& Config = Driver->GetCollectionManager().GetConfig();
// 1. 进度 UI 变金色 + 完成弹性动画(执行案第四节)
ProgressWidget->SetCompleteStyle();
ProgressWidget->PlayCompleteAnimation();
// 2. 延迟弹出完成提示(等最后一条通知消失 + CompletePromptDelay)
const float TotalDelay = Config.NotifDelay + Config.NotifStayDuration
+ Config.NotifFadeOutDuration + Config.CompletePromptDelay;
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(CompleteTimerHandle, this,
&UCollectionHUDComponent::ShowCompletionPrompt, TotalDelay, false);
}
UE_LOG(LogChildHood, Log, TEXT("CollectionHUDComponent: 触发完成序列,%.2fs 后弹出完成提示"), TotalDelay);
}
void UCollectionHUDComponent::ShowCompletionPrompt()
{
UWorld* World = GetWorld();
if (!CompleteWidgetClass || !World) return;
APlayerController* PC = World->GetFirstPlayerController();
if (!PC) return;
UCollectionCompleteWidget* CompleteWidget = CreateWidget<UCollectionCompleteWidget>(PC, CompleteWidgetClass);
if (!CompleteWidget) return;
// 完成提示参数(执行案第四节:白色大字,显示 2.0s 后淡出)
ASystemDriver* Driver = DriverPtr.Get();
const float CompleteDuration = Driver ? Driver->GetCollectionManager().GetConfig().CompletePromptDuration : 2.0f;
const float FadeOutDuration = 0.5f;
CompleteWidget->ShowComplete(TEXT("杂物全部收集完成!"), CompleteDuration, FadeOutDuration);
CompleteWidget->AddToViewport(20);
}
// ============================================================================
// 通知清理回调
// ============================================================================
void UCollectionHUDComponent::OnNotificationRemoved(UCollectionNotifWidget* Widget)
{
if (Widget)
{
ActiveNotifications.RemoveSingle(Widget);
}
}
@@ -1,102 +0,0 @@
// ChildHood Project - 收集系统表现层组件
// 对应技术方案: docs/technical/收集系统-技术方案.md 第3节(UCollectionHUDComponent)+ 第5节 + 第6节
// 职责:只读 FCollectionManager 缓存,驱动收集通知/进度/完成 UI
// 架构:UActorComponent(挂在 AChildHoodCharacter 上),自有 TickComponent,
// 只读 Manager 缓存,绝不访问 InteractableComponent 或 UPersistenceSubsystem
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "CollectionHUDComponent.generated.h"
class ASystemDriver;
class UCollectionNotifWidget;
class UCollectionProgressWidget;
class UCollectionCompleteWidget;
/**
* UCollectionHUDComponent — 收集系统表现层(执行案第二/三/四节 UI)。
*
* 数据流(技术方案第9节 Presentation 流):
* TickComponent 每帧
* → Driver->GetCollectionManager().GetCachedInfo()
* → Cache.Progress != LastSeenProgress? → UpdateProgress + SetTimer(NotifDelay) → ShowNotification
* → Cache.bJustCompleted && !bCompletionShown? → TriggerCompletionSequence
*
* 不持有 Manager 所有权,不写 Manager 状态,不碰 PersistenceSubsystem(技术方案注意事项3)。
*/
UCLASS(ClassGroup = (Collection), meta = (BlueprintSpawnableComponent))
class UCollectionHUDComponent : public UActorComponent
{
GENERATED_BODY()
public:
UCollectionHUDComponent();
/** 初始化(Character BeginPlay 调用,存 Driver 弱引用;可为空,Tick 兜底查找)*/
UFUNCTION(BlueprintCallable, Category = "Collection|HUD")
void Initialize(ASystemDriver* InDriver);
protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
// === UI 类引用(蓝图/实例设置:创建 WBP 后在此指定)===
/** 收集通知 Widget 蓝图类(WBP_CollectionNotification)*/
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Collection|UI")
TSubclassOf<UCollectionNotifWidget> NotifWidgetClass;
/** 进度 HUD Widget 蓝图类(WBP_CollectionProgress)*/
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Collection|UI")
TSubclassOf<UCollectionProgressWidget> ProgressWidgetClass;
/** 完成提示 Widget 蓝图类(WBP_CollectionComplete)*/
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Collection|UI")
TSubclassOf<UCollectionCompleteWidget> CompleteWidgetClass;
private:
// === Driver 弱引用(Tick 时优先用,失效则全局查找)===
TWeakObjectPtr<ASystemDriver> DriverPtr;
// === 变化检测(Presentation 层状态)===
int32 LastSeenProgress = -1; // 上次看到的进度,-1 = 未初始化
bool bCompletionShown = false; // 完成表现是否已显示
// === UI 实例 ===
UPROPERTY()
TArray<TObjectPtr<UCollectionNotifWidget>> ActiveNotifications;
UPROPERTY()
TObjectPtr<UCollectionProgressWidget> ProgressWidget = nullptr;
// === 定时器 ===
FTimerHandle NotifDelayTimerHandle; // 通知延迟(0.3s)
FTimerHandle CompleteTimerHandle; // 完成提示延迟
// === 内部方法 ===
/** 创建常驻进度 Widget(懒创建:首次有效 Tick 时,避免与 Driver BeginPlay 时序竞争)*/
void CreateProgressWidget(ASystemDriver* Driver);
/** 弹出收集通知(检查同屏最大数量限制)*/
void ShowNotification(const FString& Text);
/** 更新进度 UI + 弹性动画 */
void UpdateProgress(int32 Current, int32 Total);
/** 触发完成序列:进度金色 + 延迟完成提示 */
void TriggerCompletionSequence(ASystemDriver* Driver);
/** 显示完成提示(延迟后调用)*/
void ShowCompletionPrompt();
/** 通知 Widget 淡出移除回调 — 从 ActiveNotifications 清理 */
UFUNCTION()
void OnNotificationRemoved(UCollectionNotifWidget* Widget);
};
@@ -1,140 +0,0 @@
// ChildHood Project - 收集系统逻辑核心 实现
// 对应技术方案: docs/technical/收集系统-技术方案.md 第5节(数据结构)+ 第6节(行为规则实现)+ 第9节(数据流)
#include "Collection/CollectionManager.h"
#include "Interaction/PersistenceSubsystem.h"
#include "ChildHood.h"
// ============================================================================
// 初始化
// ============================================================================
void FCollectionManager::Initialize()
{
// 重置本地运行时状态
Progress = 0;
CollectedIDs.Empty();
bIsComplete = false;
// 初始化缓存(稳定态,无瞬时通知)
CachedInfo.Progress = 0;
CachedInfo.TotalCount = Config.TotalCount;
CachedInfo.bIsComplete = false;
CachedInfo.ResetTransient();
}
void FCollectionManager::RestoreFromPersistence(UPersistenceSubsystem* Subsystem)
{
// 缓存持久层弱引用,供后续 SaveStateToPersistence 使用
PersistencePtr = Subsystem;
if (!Subsystem)
{
UE_LOG(LogChildHood, Warning, TEXT("CollectionManager: RestoreFromPersistence - 持久层为空,保持初始状态"));
return;
}
// 从持久层恢复
TSet<FName> SavedIDs;
int32 SavedProgress = 0;
bool bSavedComplete = false;
Subsystem->GetCollectionState(SavedIDs, SavedProgress, bSavedComplete);
CollectedIDs = MoveTemp(SavedIDs);
Progress = SavedProgress;
bIsComplete = bSavedComplete;
// 初始化缓存(恢复后的稳定态,无瞬时通知 — 不触发 UI 动画)
CachedInfo.Progress = Progress;
CachedInfo.TotalCount = Config.TotalCount;
CachedInfo.bIsComplete = bIsComplete;
CachedInfo.ResetTransient();
UE_LOG(LogChildHood, Log, TEXT("CollectionManager: 从持久层恢复 - 进度 %d/%d, 完成 %s"),
Progress, Config.TotalCount, bIsComplete ? TEXT("是") : TEXT("否"));
}
// ============================================================================
// 物品事件入口
// ============================================================================
void FCollectionManager::OnItemObtained(FName ItemId, const FString& DisplayName, const FName& Category)
{
// 步骤1: 查品类 — 非追踪品类忽略(执行案第一节步骤1)
if (Category != Config.TrackedCategory)
{
UE_LOG(LogChildHood, Verbose, TEXT("CollectionManager: 忽略非追踪品类物品 '%s' (Category=%s)"),
*DisplayName, *Category.ToString());
return;
}
ProcessNewCollection(ItemId, DisplayName);
}
// ============================================================================
// 内部:处理新收集(步骤2-5)
// ============================================================================
void FCollectionManager::ProcessNewCollection(FName ItemId, const FString& DisplayName)
{
// 步骤2: 查重复 — 已收集则流程结束,不增加 progress,不弹收集通知
if (CollectedIDs.Contains(ItemId))
{
UE_LOG(LogChildHood, Verbose, TEXT("CollectionManager: 重复物品 '%s',忽略"), *DisplayName);
return;
}
// 步骤3: 记录 — 加入集合,Progress 取 Set 大小作为权威值
CollectedIDs.Add(ItemId);
Progress = CollectedIDs.Num();
SaveStateToPersistence();
// 步骤4: 刷新 Presentation 缓存(供 HUDComponent Tick 检测)
// LastCollectedItemId / bJustCompleted 由本方法控制;RefreshCachedInfo 只刷新进度+通知文本
CachedInfo.LastCollectedItemId = ItemId;
CachedInfo.bJustCompleted = false; // 默认非完成,步骤5判定时置 true
RefreshCachedInfo(DisplayName);
UE_LOG(LogChildHood, Log, TEXT("CollectionManager: 新收集 '%s',进度 %d/%d"),
*DisplayName, Progress, Config.TotalCount);
// 步骤5: 完成判定 — progress 达到 TotalCount 则 isComplete = true
if (Progress >= Config.TotalCount && !bIsComplete)
{
bIsComplete = true;
CachedInfo.bJustCompleted = true; // 触发 HUDComponent 完成表现
CachedInfo.bIsComplete = true;
SaveStateToPersistence();
UE_LOG(LogChildHood, Log, TEXT("CollectionManager: 全部收集完成!"));
}
}
// ============================================================================
// 内部:刷新 Presentation 缓存
// ============================================================================
void FCollectionManager::RefreshCachedInfo(const FString& DisplayName)
{
// 更新进度相关字段
CachedInfo.Progress = Progress;
CachedInfo.TotalCount = Config.TotalCount;
CachedInfo.bIsComplete = bIsComplete;
// 生成收集通知文本(执行案第二节):「发现了新的杂物,进度 X/Y」
CachedInfo.PendingNotificationText = FString::Printf(
TEXT("发现了新的杂物,进度 %d/%d"), Progress, Config.TotalCount);
// 注意:LastCollectedItemId / bJustCompleted 由 ProcessNewCollection 控制,此处不碰
}
// ============================================================================
// 内部:持久化
// ============================================================================
void FCollectionManager::SaveStateToPersistence()
{
if (UPersistenceSubsystem* Subsystem = PersistencePtr.Get())
{
Subsystem->SetCollectionState(CollectedIDs, Progress, bIsComplete);
}
}
@@ -1,84 +0,0 @@
// ChildHood Project - 收集系统逻辑核心(v1.0 架构解耦)
// 对应技术方案: docs/technical/收集系统-技术方案.md 第3节(类设计)+ 第5节(数据结构)+ 第6节(行为规则实现)
// 职责:品类检查、去重判定、记录持久化、完成判定、缓存 Presentation 数据
// 架构:普通 C++ 类(非 UObject、非 UActorComponent),由 ASystemDriver 值持有。
// 纯事件驱动无 Tick,不持有任何 UObject 强引用(与 FInteractionManager/FEffectManager 模式一致)。
#pragma once
#include "CoreMinimal.h"
#include "Collection/CollectionTypes.h"
class UPersistenceSubsystem;
/**
* FCollectionManager
*
* 收集系统逻辑核心。追踪单一品类「杂物」的收集进度(执行案 v0.1 范围)。
*
* 数据流(技术方案第9节):
* FEffectManager AddToBackpack Action
* → ASystemDriver 回调 OnItemAdded(ItemId, DisplayName, Category)
* → OnItemObtained()
* ├─ 步骤1: 查品类 (Category == Config.TrackedCategory?)
* ├─ 步骤2: 查重复 (CollectedIDs.Contains?)
* ├─ 步骤3: 记录 (CollectedIDs.Add + Progress = Num()) + SaveStateToPersistence()
* ├─ 步骤4: 刷新 Presentation 缓存(RefreshCachedInfo)
* └─ 步骤5: 完成判定 (Progress >= TotalCount) → bJustCompleted
*
* Presentation 层(UCollectionHUDComponent)每帧 Tick 只读 GetCachedInfo(),
* 通过比较 LastSeenProgress 检测变化驱动 UI,绝不访问 Manager 内部状态或 Subsystem。
*/
class FCollectionManager
{
public:
// === 初始化 ===
/** 重置本地运行时状态(由 ASystemDriver::BeginPlay 调用,先于 RestoreFromPersistence) */
void Initialize();
/** 从持久层恢复收集状态,并缓存 PersistenceSubsystem 弱引用(供后续写入用)。
* v0.1 单场景:持久层为空数据,等效于初始化为 0(技术方案第12节)。 */
void RestoreFromPersistence(UPersistenceSubsystem* Subsystem);
// === 物品事件入口(由 ASystemDriver 回调触发)===
/** 物品获得事件 — 执行查品类→查重复→记录→完成判定(技术方案步骤1-5) */
void OnItemObtained(FName ItemId, const FString& DisplayName, const FName& Category);
// === Presentation 层访问(只读)===
/** 获取 Presentation 缓存(HUDComponent 每帧只读) */
const FCollectionCachedInfo& GetCachedInfo() const { return CachedInfo; }
/** 获取配置参数(HUDComponent 读取通知/动画参数) */
const FCollectionConfig& GetConfig() const { return Config; }
private:
// === 配置 ===
FCollectionConfig Config;
// === 运行时状态 ===
int32 Progress = 0; // 当前已收集数(= CollectedIDs.Num(),权威值)
TSet<FName> CollectedIDs; // 已收集物品 ID 集合(仅追踪品类)
bool bIsComplete = false; // 是否全部收集完成
// === Presentation 缓存(供 HUDComponent 只读)===
FCollectionCachedInfo CachedInfo;
// === 持久层弱引用(RestoreFromPersistence 时设置,SaveStateToPersistence 用)===
TWeakObjectPtr<UPersistenceSubsystem> PersistencePtr;
// === 内部方法 ===
/** 处理新收集:记录 + 持久化 + 刷新缓存 + 完成判定(步骤2-5) */
void ProcessNewCollection(FName ItemId, const FString& DisplayName);
/** 刷新 Presentation 缓存(更新进度、生成通知文本)— 保持技术方案签名(DisplayName) */
void RefreshCachedInfo(const FString& DisplayName);
/** 将当前状态写入持久层(记录后 + 完成判定后调用) */
void SaveStateToPersistence();
};
@@ -1,80 +0,0 @@
// ChildHood Project - 收集通知 UI 实现
// 三段时序:淡入 → 停留 → 淡出 → 广播 OnFinished + 移除
#include "Collection/CollectionNotifWidget.h"
#include "Components/TextBlock.h"
#include "Engine/World.h"
#include "TimerManager.h"
void UCollectionNotifWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
}
void UCollectionNotifWidget::NativeDestruct()
{
// 清理计时器:StayTimer/FadeOutTimer 的 lambda 捕获裸 this,Widget 销毁后 fire 会野指针
// (与 CollectionHUDComponent::EndPlay 同类卫生写法,防关卡切换/退 PIE 时的悬挂 timer)
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(StayTimerHandle);
World->GetTimerManager().ClearTimer(FadeOutTimerHandle);
}
Super::NativeDestruct();
}
void UCollectionNotifWidget::InitializeNotif(const FString& Text, FLinearColor TextColor,
float InFadeInDuration, float InStayDuration, float InFadeOutDuration)
{
CachedFadeOutDuration = InFadeOutDuration;
// 设置文字 + 颜色
if (NotifTextBlock)
{
NotifTextBlock->SetText(FText::FromString(Text));
NotifTextBlock->SetColorAndOpacity(TextColor);
}
// 淡入动画
if (FadeInAnim)
{
PlayAnimation(FadeInAnim);
}
// 停留时长后触发淡出
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(StayTimerHandle, [this]()
{
StartFadeOut();
}, InStayDuration, false);
}
}
void UCollectionNotifWidget::StartFadeOut()
{
// 淡出动画
if (FadeOutAnim)
{
PlayAnimation(FadeOutAnim);
}
// 淡出时长后移除(用计时器而非动画完成委托,避免委托绑定复杂度)
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(FadeOutTimerHandle, [this]()
{
OnFadeOutComplete();
}, CachedFadeOutDuration, false);
}
}
void UCollectionNotifWidget::OnFadeOutComplete()
{
// 通知 HUDComponent 从 ActiveNotifications 移除本 Widget
OnFinished.ExecuteIfBound(this);
// 从视口移除自己
RemoveFromParent();
}
@@ -1,78 +0,0 @@
// ChildHood Project - 收集通知 UI 基类
// 对应技术方案: docs/technical/收集系统-技术方案.md 第3节(UCollectionNotifWidget)+ 第7节(美术资源)
// 职责:单条收集通知的三段生命周期(淡入→停留→淡出→移除)
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "CollectionNotifWidget.generated.h"
class UTextBlock;
class UCollectionNotifWidget;
/** 收集通知单条完成事件 — 淡出移除后广播,HUDComponent 据此从 ActiveNotifications 清理 */
DECLARE_DELEGATE_OneParam(FOnCollectionNotifFinished, UCollectionNotifWidget*);
/**
* UCollectionNotifWidget — 收集通知 UI 单条实例(执行案第二节「收集通知 UI」)。
*
* 三段生命周期:淡入(0.2s) → 停留(2.0s) → 淡出(0.3s) → 广播 OnFinished + RemoveFromParent。
* 文字浅金色 #F0D060(与拾取通知白色区分)。
*
* 绑定要求(蓝图 WBP_CollectionNotification 继承本类):
* - TextBlock 名 "NotifTextBlock" — 通知文字
* - WidgetAnimation 名 "FadeInAnim" — 淡入动画
* - WidgetAnimation 名 "FadeOutAnim" — 淡出动画
*/
UCLASS(Abstract, Blueprintable)
class UCollectionNotifWidget : public UUserWidget
{
GENERATED_BODY()
public:
/** 初始化通知:设置文字/颜色,启动淡入→停留→淡出三段时序 */
UFUNCTION(BlueprintCallable, Category = "Collection|Notification")
void InitializeNotif(const FString& Text, FLinearColor TextColor,
float InFadeInDuration, float InStayDuration, float InFadeOutDuration);
/** 通知完成事件(淡出移除后广播)— HUDComponent 绑定后清理 ActiveNotifications */
FOnCollectionNotifFinished OnFinished;
protected:
/** 通知文字 TextBlock,蓝图子类必须有同名 TextBlock */
UPROPERTY(meta = (BindWidget))
UTextBlock* NotifTextBlock;
/** 淡入动画,蓝图子类必须有同名 WidgetAnimation */
UPROPERTY(meta = (BindWidgetAnim), Transient)
UWidgetAnimation* FadeInAnim;
/** 淡出动画,蓝图子类必须有同名 WidgetAnimation */
UPROPERTY(meta = (BindWidgetAnim), Transient)
UWidgetAnimation* FadeOutAnim;
/** 停留计时器(淡入结束后启动,到时触发淡出)*/
FTimerHandle StayTimerHandle;
/** 淡出计时器(淡出开始后启动,到时移除 Widget)*/
FTimerHandle FadeOutTimerHandle;
/** 淡出时长(缓存,供淡出计时器用)*/
float CachedFadeOutDuration = 0.3f;
virtual void NativeOnInitialized() override;
/** Widget 销毁时清理计时器:lambda 捕获裸 this,销毁后 fire 会野指针 */
virtual void NativeDestruct() override;
private:
/** 停留结束 → 播放淡出动画 */
void StartFadeOut();
/** 淡出时长到 → 广播 OnFinished + RemoveFromParent */
void OnFadeOutComplete();
};
@@ -1,44 +0,0 @@
// ChildHood Project - 收集进度 UI 实现
#include "Collection/CollectionProgressWidget.h"
#include "Components/TextBlock.h"
void UCollectionProgressWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
}
void UCollectionProgressWidget::UpdateProgress(int32 Current, int32 Total)
{
if (ProgressTextBlock)
{
// 格式:「📦 杂物 X/Y」(执行案第三节)。图标 emoji 在文本中,品类名 v0.1 固定「杂物」。
ProgressTextBlock->SetText(FText::FromString(
FString::Printf(TEXT("📦 杂物 %d/%d"), Current, Total)));
}
}
void UCollectionProgressWidget::SetCompleteStyle()
{
if (ProgressTextBlock)
{
// 文字变金色(全部收集完成表现)
ProgressTextBlock->SetColorAndOpacity(CompleteColor);
}
}
void UCollectionProgressWidget::PlayBounceAnimation()
{
if (BounceAnim)
{
PlayAnimation(BounceAnim);
}
}
void UCollectionProgressWidget::PlayCompleteAnimation()
{
if (CompleteAnim)
{
PlayAnimation(CompleteAnim);
}
}
@@ -1,72 +0,0 @@
// ChildHood Project - 收集进度 UI 基类
// 对应技术方案: docs/technical/收集系统-技术方案.md 第3节(UCollectionProgressWidget)+ 第7节
// 职责:常驻进度 HUD(图标+品类名+进度文本)+ 数字弹性动画 + 完成金色变色
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "CollectionProgressWidget.generated.h"
class UTextBlock;
/**
* UCollectionProgressWidget — 收集进度常驻 HUD(执行案第三节「收集进度 UI」)。
*
* 显示格式:「📦 杂物 X/Y」,游戏开始后常驻左上角。
* 进度变化时数字弹性缩放;全部完成时文字变金色 + 完成动画。
*
* 绑定要求(蓝图 WBP_CollectionProgress 继承本类):
* - TextBlock 名 "ProgressTextBlock" — 进度文本(如 "📦 杂物 3/10")
* - WidgetAnimation 名 "BounceAnim" — 数字弹性动画(1.2x, 0.2s)
* - WidgetAnimation 名 "CompleteAnim" — 完成金色动画(1.4x, 0.5s)
*
* 布局要求:Anchors Top Left,距左/上各 3% 屏幕边距(执行案第三节)。
*/
UCLASS(Abstract, Blueprintable)
class UCollectionProgressWidget : public UUserWidget
{
GENERATED_BODY()
public:
/** 更新进度文本(格式:「📦 杂物 X/Y」)*/
UFUNCTION(BlueprintCallable, Category = "Collection|Progress")
void UpdateProgress(int32 Current, int32 Total);
/** 设为完成样式:文字变金色(全部收集完成时)*/
UFUNCTION(BlueprintCallable, Category = "Collection|Progress")
void SetCompleteStyle();
/** 播放数字弹性动画(单次新收集时)*/
UFUNCTION(BlueprintCallable, Category = "Collection|Progress")
void PlayBounceAnimation();
/** 播放完成动画(全部收集完成时,配合 SetCompleteStyle)*/
UFUNCTION(BlueprintCallable, Category = "Collection|Progress")
void PlayCompleteAnimation();
protected:
/** 进度文本 TextBlock,蓝图子类必须有同名 TextBlock */
UPROPERTY(meta = (BindWidget))
UTextBlock* ProgressTextBlock;
/** 数字弹性动画,蓝图子类必须有同名 WidgetAnimation */
UPROPERTY(meta = (BindWidgetAnim), Transient)
UWidgetAnimation* BounceAnim;
/** 完成金色动画,蓝图子类必须有同名 WidgetAnimation */
UPROPERTY(meta = (BindWidgetAnim), Transient)
UWidgetAnimation* CompleteAnim;
/** 正常文字颜色(白色)*/
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Collection|Progress")
FLinearColor NormalColor = FLinearColor::White;
/** 完成文字颜色(浅金色 #F0D060)*/
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Collection|Progress")
FLinearColor CompleteColor = FLinearColor(0.94f, 0.82f, 0.38f, 1.f);
virtual void NativeOnInitialized() override;
};
@@ -1,162 +0,0 @@
// ChildHood Project - 收集系统数据类型定义(v1.0 架构解耦)
// 对应技术方案: docs/technical/收集系统-技术方案.md 第5节(核心数据结构)
// 职责:FCollectionConfig(配置,USTRUCT)+ FCollectionCachedInfo(Presentation 缓存,纯 C++)
//
// 设计说明:
// - FCollectionConfig 为 USTRUCT,承载全部可调参数,默认值与执行案「可调参数汇总」一一对应。
// v0.1 以代码默认值承载,后续版本可迁入 DataAsset / 配置表(数据驱动原则)。
// - FCollectionCachedInfo 为普通 struct(非 USTRUCT):仅在 FCollectionManager(普通 C++)
// 与 UCollectionHUDComponent(UActorComponent)之间传递,无需反射。
#pragma once
#include "CoreMinimal.h"
#include "CollectionTypes.generated.h"
// ============================================================================
// FCollectionConfig — 收集系统配置(FCollectionManager 使用)
// ============================================================================
/**
* FCollectionConfig
*
* 收集系统全部可调参数。承载追踪品类、收集通知、进度 UI、完成表现四组参数。
* 所有 ClampMin/ClampMax 与执行案「可调参数汇总」的可调范围一致。
*/
USTRUCT(BlueprintType)
struct FCollectionConfig
{
GENERATED_BODY()
// === 追踪品类 ===
/** 追踪的物品品类名称(v0.1 固定为 "杂物") */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Category")
FName TrackedCategory = FName("杂物");
/** 品类总收集数(v0.1 = 10) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Category",
meta = (ClampMin = "1"))
int32 TotalCount = 10;
// === 收集通知参数 ===
/** 拾取通知后到收集通知弹出的间隔(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Notification",
meta = (ClampMin = "0.1", ClampMax = "0.5"))
float NotifDelay = 0.3f;
/** 通知淡入时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Notification",
meta = (ClampMin = "0.1", ClampMax = "0.3"))
float NotifFadeInDuration = 0.2f;
/** 通知停留时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Notification",
meta = (ClampMin = "1.5", ClampMax = "3.0"))
float NotifStayDuration = 2.0f;
/** 通知淡出时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Notification",
meta = (ClampMin = "0.2", ClampMax = "0.5"))
float NotifFadeOutDuration = 0.3f;
/** 通知文字颜色(浅金色 #F0D060,与拾取通知白色区分) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Notification")
FLinearColor NotifTextColor = FLinearColor(0.94f, 0.82f, 0.38f, 1.f);
/** 最大同屏收集通知数 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Notification",
meta = (ClampMin = "1", ClampMax = "3"))
int32 MaxSimultaneousNotifs = 2;
// === 进度 UI 参数 ===
/** 进度 UI 距左边距(屏幕宽度比例) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Progress",
meta = (ClampMin = "0.02", ClampMax = "0.05"))
float ProgressMarginX = 0.03f;
/** 进度 UI 距上边距(屏幕高度比例) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Progress",
meta = (ClampMin = "0.02", ClampMax = "0.05"))
float ProgressMarginY = 0.03f;
/** 进度数字弹性缩放倍率(单次新收集时) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Progress",
meta = (ClampMin = "1.1", ClampMax = "1.5"))
float BounceScale = 1.2f;
/** 进度数字弹性时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Progress",
meta = (ClampMin = "0.1", ClampMax = "0.4"))
float BounceDuration = 0.2f;
// === 完成表现参数 ===
/** 完成时金色缩放倍率(全部收集完成时) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Complete",
meta = (ClampMin = "1.2", ClampMax = "1.8"))
float CompleteScale = 1.4f;
/** 完成缩放时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Complete",
meta = (ClampMin = "0.3", ClampMax = "0.8"))
float CompleteScaleDuration = 0.5f;
/** 收集通知消失后到完成提示弹出的间隔(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Complete",
meta = (ClampMin = "0.3", ClampMax = "1.0"))
float CompletePromptDelay = 0.5f;
/** 完成提示显示时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Collection|Complete",
meta = (ClampMin = "1.5", ClampMax = "3.0"))
float CompletePromptDuration = 2.0f;
};
// ============================================================================
// FCollectionCachedInfo — Presentation 层缓存(非 UObject,纯 C++)
// ============================================================================
/**
* FCollectionCachedInfo
*
* 收集系统 Presentation 层缓存。与交互系统的 FSelectedInfo 模式一致。
*
* 写入方:FCollectionManager 在每次 OnItemObtained 成功记录新收集后,由
* RefreshCachedInfo() 刷新此结构体。
* 读取方:UCollectionHUDComponent 的 TickComponent 每帧只读此缓存,通过比较
* LastSeenProgress 检测变化,驱动 UI 更新。
*
* 表现层绝不直接访问 FCollectionManager 的内部状态或 UPersistenceSubsystem,
* 只通过此缓存 + GetConfig() 读取参数。
*/
struct FCollectionCachedInfo
{
/** 当前进度(已收集数) */
int32 Progress = 0;
/** 总数 */
int32 TotalCount = 10;
/** 是否已全部完成 */
bool bIsComplete = false;
/** 是否刚刚完成(本次 OnItemObtained 触发了完成判定) */
bool bJustCompleted = false;
/** 待显示的通知文本(如 "发现了新的杂物,进度 3/10") */
FString PendingNotificationText;
/** 本次收集的物品 ID(用于通知回调定位 / 调试) */
FName LastCollectedItemId;
/** 重置瞬时态(每次消费后调用,保留 Progress/TotalCount/bIsComplete) */
void ResetTransient()
{
bJustCompleted = false;
PendingNotificationText.Empty();
LastCollectedItemId = FName();
}
};
@@ -1,48 +0,0 @@
// ChildHood Project - 门 Actor 实现
#include "Interaction/DoorActor.h"
#include "Interaction/DoorInteractableComponent.h"
#include "Interaction/InteractableComponent.h"
#include "ChildHood.h"
// ============================================================================
// 构造函数
// ============================================================================
ADoorActor::ADoorActor()
{
PrimaryActorTick.bCanEverTick = false;
// 创建门专用交互组件,赋值给基类指针
DoorInteractableComp = CreateDefaultSubobject<UDoorInteractableComponent>(TEXT("DoorInteractableComp"));
InteractableComp = DoorInteractableComp;
}
// ============================================================================
// 交互回调
// ============================================================================
void ADoorActor::OnInteractStarted_Implementation(UInteractableComponent* Comp)
{
(void)Comp; // 参数保留以匹配基类签名(OnInteractStarted 当前无调用方)
// v1.1 架构:门的开关业务逻辑由 EffectManager::ExecuteToggleDoor 统一驱动,
// 视觉旋转由 UDoorInteractableComponent::ToggleDoor 内部回调 UpdateDoorVisual 完成。
// 此处保留空实现,避免与 Effect 路径重复 ToggleDoor 导致状态抵消。
}
// ============================================================================
// 视觉更新
// ============================================================================
void ADoorActor::UpdateDoorVisual(bool bIsOpen)
{
if (!MeshComp) return;
// 瞬时旋转:开门偏移 OpenYawOffset 度,关门回到 0 度
const float TargetYaw = bIsOpen ? OpenYawOffset : 0.f;
MeshComp->SetRelativeRotation(FRotator(0.f, TargetYaw, 0.f));
UE_LOG(LogChildHood, Verbose, TEXT("DoorActor Visual: Yaw=%.0f (%s)"),
TargetYaw, bIsOpen ? TEXT("Open") : TEXT("Closed"));
}
-68
View File
@@ -1,68 +0,0 @@
// ChildHood Project - 门 Actor
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:门的视觉表现(Mesh 旋转)、交互回调分发
// 派生自 AInteractableActor,使用 UDoorInteractableComponent 替代基类组件
#pragma once
#include "CoreMinimal.h"
#include "Interaction/InteractableActor.h"
#include "DoorActor.generated.h"
class UDoorInteractableComponent;
/**
* ADoorActor
*
* 门类型的交互物 Actor。在基类基础上:
* - 使用 UDoorInteractableComponent 替代 UInteractableComponent
* - v1.1:门开关业务由 EffectManager::ExecuteToggleDoor 驱动,OnInteractStarted 为空实现
* - UpdateDoorVisual 执行实际的 Mesh Yaw 旋转(开门+90, 关门0)
*
* v0.1 阶段旋转为瞬时 SetRelativeRotation,不使用动画插值。
*/
UCLASS()
class ADoorActor : public AInteractableActor
{
GENERATED_BODY()
public:
ADoorActor();
// === 组件访问 ===
/** 获取门交互组件 */
UFUNCTION(BlueprintCallable, Category = "Door")
UDoorInteractableComponent* GetDoorInteractableComp() const { return DoorInteractableComp; }
// === 交互回调 ===
/**
* 交互开始回调(v1.1 为空实现)。
* 门开关业务逻辑由 EffectManager::ExecuteToggleDoor 统一驱动,
* 视觉旋转由 UDoorInteractableComponent::ToggleDoor 内部回调,Actor 不在此重复处理。
*/
virtual void OnInteractStarted_Implementation(UInteractableComponent* Comp) override;
// === 视觉更新 ===
/**
* 更新门 Mesh 的视觉旋转。
* bIsOpen=true → Yaw +90 度(打开)
* bIsOpen=false → Yaw 0 度(关闭)
* 由 UDoorInteractableComponent 在 ToggleDoor/RestorePersistentState 中调用。
*/
UFUNCTION(BlueprintCallable, Category = "Door")
void UpdateDoorVisual(bool bIsOpen);
protected:
/** 门交互组件(替代基类的 InteractableComp,提供门特有状态和行为) */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UDoorInteractableComponent> DoorInteractableComp;
/** 门打开时的 Yaw 偏移(度),可在编辑器中调整 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Door", meta = (ClampMin = "0", ClampMax = "180"))
float OpenYawOffset = 90.f;
};
@@ -1,89 +0,0 @@
// ChildHood Project - 门交互组件 实现
#include "Interaction/DoorInteractableComponent.h"
#include "Interaction/DoorActor.h"
#include "Interaction/PersistenceSubsystem.h"
#include "ChildHood.h"
#include "Engine/World.h"
#include "Engine/GameInstance.h"
// ============================================================================
// 构造函数
// ============================================================================
UDoorInteractableComponent::UDoorInteractableComponent()
{
// 门不需要 Tick,状态变更由交互事件驱动
PrimaryComponentTick.bCanEverTick = false;
}
// ============================================================================
// 门操作
// ============================================================================
void UDoorInteractableComponent::ToggleDoor()
{
bDoorIsOpen = !bDoorIsOpen;
// 通知 ADoorActor 更新视觉旋转
if (AActor* Owner = GetOwner())
{
if (ADoorActor* DoorActor = Cast<ADoorActor>(Owner))
{
DoorActor->UpdateDoorVisual(bDoorIsOpen);
}
}
// 持久化门状态
if (UWorld* World = GetWorld())
{
if (UPersistenceSubsystem* Persistence = World->GetGameInstance()->GetSubsystem<UPersistenceSubsystem>())
{
Persistence->SetDoorOpen(Data.ItemId, bDoorIsOpen);
}
}
UE_LOG(LogChildHood, Log, TEXT("Door Toggle: %s → %s"),
*Data.ItemId.ToString(),
bDoorIsOpen ? TEXT("Open") : TEXT("Closed"));
}
// ============================================================================
// 提示文本
// ============================================================================
FString UDoorInteractableComponent::GetPromptText_Implementation() const
{
// 根据门状态选择动词:已打开用 Alt 动词(如"关上"),已关闭用主动词(如"打开")
const FString& Verb = bDoorIsOpen ? Data.InteractVerbAlt : Data.InteractVerb;
return FString::Printf(TEXT("[E] %s %s"), *Verb, *Data.DisplayName);
}
// ============================================================================
// 跨场景持久状态恢复
// ============================================================================
void UDoorInteractableComponent::RestorePersistentState(UPersistenceSubsystem* Persistence)
{
if (!Persistence) return;
// 1. 先调用基类恢复消耗状态
Super::RestorePersistentState(Persistence);
// 2. 恢复门开关状态
bDoorIsOpen = Persistence->IsDoorOpen(Data.ItemId);
// 3. 通知 DoorActor 更新视觉旋转
if (AActor* Owner = GetOwner())
{
if (ADoorActor* DoorActor = Cast<ADoorActor>(Owner))
{
DoorActor->UpdateDoorVisual(bDoorIsOpen);
}
}
UE_LOG(LogChildHood, Log, TEXT("Door RestorePersistentState: %s → %s"),
*Data.ItemId.ToString(),
bDoorIsOpen ? TEXT("Open") : TEXT("Closed"));
}
@@ -1,64 +0,0 @@
// ChildHood Project - 门交互组件
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:门的开关状态管理、动态提示文本、跨场景持久状态恢复
// 派生自 UInteractableComponent,增加门特有的状态和行为
#pragma once
#include "CoreMinimal.h"
#include "Interaction/InteractableComponent.h"
#include "DoorInteractableComponent.generated.h"
class UPersistenceSubsystem;
/**
* UDoorInteractableComponent
*
* 挂载在 ADoorActor 上的门交互组件。
* 在基类(消耗状态、触发检测、Effect 配置)基础上,增加:
* - 门开关状态 (bDoorIsOpen)
* - 切换门 (ToggleDoor)
* - 动态提示文本(根据开关状态选择 InteractVerb 或 InteractVerbAlt)
* - 持久状态恢复(门的开关状态)
*/
UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent))
class UDoorInteractableComponent : public UInteractableComponent
{
GENERATED_BODY()
public:
UDoorInteractableComponent();
// === 门操作 ===
/** 切换门的开关状态,并通过 GetOwner() 通知 ADoorActor 更新视觉 */
UFUNCTION(BlueprintCallable, Category = "Door")
void ToggleDoor();
/** 查询门当前是否打开 */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Door")
bool IsDoorOpen() const { return bDoorIsOpen; }
// === 基类覆盖 ===
/**
* 根据门开关状态选择提示动词:
* 已打开 → InteractVerbAlt(如"关上")
* 已关闭 → InteractVerb(如"打开")
*/
virtual FString GetPromptText_Implementation() const override;
/**
* 恢复跨场景持久状态:
* 1. 调用 Super 恢复消耗状态
* 2. 从 PersistenceSubsystem 恢复门开关状态
* 3. 通知 DoorActor 更新视觉旋转
*/
virtual void RestorePersistentState(UPersistenceSubsystem* Persistence) override;
private:
/** 门开关状态(true=打开,false=关闭) */
bool bDoorIsOpen = false;
};
@@ -1,613 +0,0 @@
// ChildHood Project - Effect Timeline 调度器 实现
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节 + 第6节
#include "Interaction/EffectManager.h"
#include "Engine/World.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/Character.h"
#include "Interaction/InteractableComponent.h"
#include "Interaction/PersistenceSubsystem.h"
#include "Interaction/PushCameraActor.h"
#include "Interaction/NarrativeTextWidget.h"
#include "Interaction/PickupNotificationWidget.h"
#include "Blueprint/UserWidget.h"
#include "Components/SphereComponent.h"
#include "Interaction/DoorInteractableComponent.h"
#include "Interaction/DoorActor.h"
#include "Kismet/GameplayStatics.h"
#include "Animation/AnimInstance.h"
// ============================================================================
// 公开接口
// ============================================================================
void FEffectManager::SetOnEffectComplete(FOnEffectComplete&& Callback)
{
OnEffectComplete = MoveTemp(Callback);
}
void FEffectManager::SetOnItemAddedCallback(FOnItemAdded&& Callback)
{
OnItemAdded = MoveTemp(Callback);
}
void FEffectManager::SetWidgetClasses(TSubclassOf<UNarrativeTextWidget> NarrativeClass,
TSubclassOf<UPickupNotificationWidget> PickupClass)
{
NarrativeWidgetClass = NarrativeClass;
PickupWidgetClass = PickupClass;
UE_LOG(LogTemp, Log, TEXT("EffectManager: WidgetClasses 注入 — Narrative=%s, Pickup=%s"),
NarrativeClass ? *NarrativeClass->GetName() : TEXT("(null,日志占位)"),
PickupClass ? *PickupClass->GetName() : TEXT("(null,日志占位)"));
}
void FEffectManager::PlayEffect(const FInteractEffect& Effect, const FEffectContext& Context)
{
// 如果有正在播放的 Effect,先强制清理
if (bIsPlaying)
{
PerformCleanup();
}
CurrentEffect = Effect;
CurrentContext = Context;
ElapsedTime = 0.f;
NextActionIndex = 0;
bIsPlaying = true;
bIsWaitingForInput = false;
TurnState.Reset();
LastNarrativeIndex = -1;
CachedPushCamera = nullptr;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: PlayEffect '%s' (Duration=%.2f, Actions=%d)"),
*CurrentEffect.EffectName, CurrentEffect.TotalDuration, CurrentEffect.Actions.Num());
}
void FEffectManager::CancelEffect()
{
if (!bIsPlaying) return;
if (!CurrentEffect.bCancellable) return;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: CancelEffect (WaitingForInput=%s)"),
bIsWaitingForInput ? TEXT("true") : TEXT("false"));
if (bIsWaitingForInput)
{
// 用户输入是期望的恢复信号 -> 走正常恢复
ResumeFromWait();
return;
}
// 非暂停态强制取消
bIsPlaying = false;
PerformCleanup();
}
// ============================================================================
// 每帧更新
// ============================================================================
void FEffectManager::Update(float DeltaTime)
{
if (!bIsPlaying) return;
if (bIsWaitingForInput) return; // 暂停等待用户输入
// 1. 推进时间
ElapsedTime += DeltaTime;
// 2. 检查是否到达 TotalDuration
if (ElapsedTime >= CurrentEffect.TotalDuration)
{
FinishEffect();
return;
}
// 3. 触发到时的 Action
while (NextActionIndex < CurrentEffect.Actions.Num())
{
const FInteractAction& Action = CurrentEffect.Actions[NextActionIndex];
if (ElapsedTime >= Action.TriggerTime)
{
ExecuteAction(Action);
NextActionIndex++;
}
else
{
break;
}
}
// 4. 更新 TurnToTarget 插值
UpdateTurn(DeltaTime);
}
// ============================================================================
// Action 分发
// ============================================================================
void FEffectManager::ExecuteAction(const FInteractAction& Action)
{
switch (Action.Type)
{
case EInteractActionType::Wait:
ExecuteWait(Action);
break;
case EInteractActionType::TurnToTarget:
ExecuteTurnToTarget(Action);
break;
case EInteractActionType::PlayMontage:
ExecutePlayMontage(Action);
break;
case EInteractActionType::CameraPush:
ExecuteCameraPush(Action);
break;
case EInteractActionType::CameraReturn:
ExecuteCameraReturn(Action);
break;
case EInteractActionType::Consume:
ExecuteConsume();
break;
case EInteractActionType::HideActor:
ExecuteHideActor();
break;
case EInteractActionType::ShowActor:
ExecuteShowActor();
break;
case EInteractActionType::ShowNarrative:
ExecuteShowNarrative(Action);
break;
case EInteractActionType::HideNarrative:
ExecuteHideNarrative();
break;
case EInteractActionType::ShowPickupNotification:
ExecuteShowPickupNotification();
break;
case EInteractActionType::ToggleDoor:
ExecuteToggleDoor();
break;
// AddToBackpack:通过 Driver 回调链通知收集系统(收集系统技术方案第13节)
case EInteractActionType::AddToBackpack:
if (CurrentContext.TargetComp.IsValid())
{
const FInteractableData& ItemData = CurrentContext.TargetComp->GetInteractableData();
if (OnItemAdded.IsBound())
{
// 回调连线:EffectManager → ASystemDriver → FCollectionManager::OnItemObtained
OnItemAdded.Execute(ItemData.ItemId, ItemData.DisplayName, ItemData.Category);
}
UE_LOG(LogTemp, Log, TEXT("EffectManager: AddToBackpack - Item: %s (Category: %s)"),
*ItemData.DisplayName, *ItemData.Category.ToString());
}
break;
case EInteractActionType::PlayMontageOnTarget:
case EInteractActionType::PlayDialogue:
case EInteractActionType::PlayStory:
case EInteractActionType::BroadcastEvent:
UE_LOG(LogTemp, Log, TEXT("EffectManager: Action %d not implemented in v0.1"),
static_cast<int32>(Action.Type));
break;
}
}
// ============================================================================
// Effect 结束 / 清理 / 恢复
// ============================================================================
void FEffectManager::FinishEffect()
{
bIsPlaying = false;
PerformCleanup();
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: FinishEffect '%s'"), *CurrentEffect.EffectName);
// 广播完成回调
if (OnEffectComplete.IsBound())
{
OnEffectComplete.Execute();
}
}
void FEffectManager::ResumeFromWait()
{
bIsWaitingForInput = false;
// Timeline 继续推进,后续 Action 按 TriggerTime 正常触发
}
void FEffectManager::PerformCleanup()
{
// 1. 文本底框强制淡出移除(ShowNarrative 曾显示但未 HideNarrative 时兜底)
RemoveNarrativeWidget();
RemovePickupWidget();
// 2. 相机回位(如果推镜过)
if (CachedPushCamera.IsValid())
{
if (CurrentContext.Instigator.IsValid())
{
APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController());
if (PC)
{
PC->SetViewTargetWithBlend(CurrentContext.Instigator.Get(), 0.2f);
}
}
// 销毁推镜摄像机
APushCameraActor* PushCam = CachedPushCamera.Get();
CachedPushCamera = nullptr;
if (PushCam && PushCam->GetWorld())
{
PushCam->Destroy();
}
}
// 3. 蒙太奇停止
if (CurrentContext.Instigator.IsValid())
{
ACharacter* Char = CurrentContext.Instigator.Get();
if (Char->GetMesh() && Char->GetMesh()->GetAnimInstance())
{
Char->GetMesh()->GetAnimInstance()->Montage_Stop(0.2f);
}
}
// 4. TurnToTarget 未完成 -> 瞬移到目标旋转
if (TurnState.bIsActive && CurrentContext.Instigator.IsValid())
{
CurrentContext.Instigator.Get()->SetActorRotation(TurnState.TargetRotation);
TurnState.Reset();
}
// 5. 重置所有状态变量
ElapsedTime = 0.f;
NextActionIndex = 0;
bIsWaitingForInput = false;
TurnState.Reset();
}
// ============================================================================
// 各 Action 实现
// ============================================================================
void FEffectManager::ExecuteWait(const FInteractAction& Action)
{
// Wait Action 由 Timeline 自然推进处理,此处仅做日志
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: Wait (%.2fs)"), Action.WaitDuration);
}
void FEffectManager::ExecuteTurnToTarget(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid() || !CurrentContext.TargetActor.IsValid()) return;
ACharacter* Char = CurrentContext.Instigator.Get();
FVector DirToTarget = CurrentContext.TargetActor->GetActorLocation() - Char->GetActorLocation();
DirToTarget.Z = 0;
DirToTarget.Normalize();
TurnState.bIsActive = true;
TurnState.StartRotation = Char->GetActorRotation();
TurnState.TargetRotation = DirToTarget.Rotation();
TurnState.ElapsedTime = 0.f;
TurnState.Duration = Action.TurnDuration;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: TurnToTarget (Duration=%.2fs)"), Action.TurnDuration);
}
void FEffectManager::ExecutePlayMontage(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid()) return;
if (!Action.Montage) return;
ACharacter* Char = CurrentContext.Instigator.Get();
if (Char->GetMesh() && Char->GetMesh()->GetAnimInstance())
{
Char->GetMesh()->GetAnimInstance()->Montage_Play(Action.Montage);
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: PlayMontage '%s'"),
*Action.Montage->GetName());
}
}
void FEffectManager::ExecuteCameraPush(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid()) return;
APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController());
if (!PC) return;
// 计算推镜摄像机位置:沿角色到目标方向偏移
FVector CharLoc = CurrentContext.Instigator.Get()->GetActorLocation();
FVector DirToTarget(0.f, 0.f, 0.f);
if (CurrentContext.TargetActor.IsValid())
{
DirToTarget = (CurrentContext.TargetActor->GetActorLocation() - CharLoc).GetSafeNormal();
}
else
{
// 没有目标 Actor 时使用角色朝向
DirToTarget = CurrentContext.Instigator.Get()->GetActorForwardVector();
}
FVector PushLoc = CharLoc + DirToTarget * Action.PushDistance + FVector(0.f, 0.f, 80.f);
FActorSpawnParameters Params;
Params.Owner = CurrentContext.Instigator.Get();
APushCameraActor* PushCam = CurrentContext.Instigator.Get()->GetWorld()->SpawnActor<APushCameraActor>(
PushLoc, DirToTarget.Rotation(), Params);
if (PushCam)
{
PC->SetViewTargetWithBlend(PushCam, Action.PushDuration);
CachedPushCamera = PushCam;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: CameraPush (Dist=%.1f, Dur=%.2fs)"),
Action.PushDistance, Action.PushDuration);
}
}
void FEffectManager::ExecuteCameraReturn(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid()) return;
APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController());
if (!PC) return;
PC->SetViewTargetWithBlend(CurrentContext.Instigator.Get(), Action.ReturnDuration);
// 相机已回到角色,立即销毁推镜摄像机并清空缓存,
// 避免 PerformCleanup 检测到有效缓存后冗余回位 + 重复销毁。
if (APushCameraActor* PushCam = CachedPushCamera.Get())
{
CachedPushCamera = nullptr;
if (PushCam->GetWorld())
{
PushCam->Destroy();
}
}
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: CameraReturn (Dur=%.2fs)"), Action.ReturnDuration);
}
void FEffectManager::ExecuteConsume()
{
if (!CurrentContext.TargetComp.IsValid()) return;
CurrentContext.TargetComp->Consume();
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: Consume - %s"),
*CurrentContext.TargetComp->GetInteractableData().DisplayName);
}
void FEffectManager::ExecuteHideActor()
{
if (!CurrentContext.TargetActor.IsValid()) return;
CurrentContext.TargetActor.Get()->SetActorHiddenInGame(true);
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: HideActor"));
}
void FEffectManager::ExecuteShowActor()
{
if (!CurrentContext.TargetActor.IsValid()) return;
CurrentContext.TargetActor.Get()->SetActorHiddenInGame(false);
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: ShowActor"));
}
void FEffectManager::ExecuteShowNarrative(const FInteractAction& Action)
{
FString SelectedText;
if (Action.bRandomSelect && Action.NarrativeTexts.Num() > 1)
{
SelectedText = PickRandomNarrative(Action.NarrativeTexts);
}
else if (Action.NarrativeTexts.Num() > 0)
{
SelectedText = Action.NarrativeTexts[0];
}
// P-016:接通真实 Widget(半透明底框 + 整句一次显示 + 淡入)
if (NarrativeWidgetClass && CurrentContext.Instigator.IsValid())
{
if (APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController()))
{
// 若上一次的 Widget 仍在,先移除(避免叠加)
RemoveNarrativeWidget();
UNarrativeTextWidget* Widget = CreateWidget<UNarrativeTextWidget>(PC, NarrativeWidgetClass);
if (Widget)
{
Widget->AddToViewport();
Widget->SetNarrativeText(SelectedText);
Widget->Show();
CachedNarrativeWidget = Widget;
UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowNarrative (Widget) - %s"), *SelectedText);
}
}
}
else
{
// Widget Class 未注入(蓝图未创建/未配置)→ 回退日志占位,保证 Effect 流程不卡
UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowNarrative (日志占位,WBP_NarrativeText 未配置) - %s"),
*SelectedText);
}
if (Action.bWaitForInput)
{
bIsWaitingForInput = true;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: WaitForInput paused"));
}
}
void FEffectManager::ExecuteHideNarrative()
{
// P-016:淡出叙事文本 Widget(执行案要求 0.2s 淡出)
//
// 对称 Pickup 路径的清理机制(修复 reviewer 建议关注项 1:淡出后视口残留累积):
// - 不立即置空缓存(原 bug:调用 Hide() 后立即 CachedNarrativeWidget = nullptr,
// 但 Hide() 只淡出 + SetVisibility(Hidden) 不 RemoveFromParent,导致残留 Widget
// 实例仍挂在视口 Root,且缓存已空 → PerformCleanup / 下次 ShowNarrative 开头的
// RemoveNarrativeWidget 因 IsValid 返回 false 检测不到残留,每次交互循环累积一个实例)。
// - 现在与 Pickup 对称:Hide() 后保留缓存,由 RemoveNarrativeWidget
// (PerformCleanup 兜底 / 下次 ShowNarrative 开头)检测到有效缓存并 RemoveFromParent,
// 保证「淡出 → 下次清理 RemoveFromParent」闭环。
if (CachedNarrativeWidget.IsValid())
{
CachedNarrativeWidget->Hide();
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: HideNarrative (Widget 淡出,下次清理时移除)"));
}
else
{
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: HideNarrative (无活动 Widget)"));
}
}
void FEffectManager::ExecuteShowPickupNotification()
{
FString DisplayName;
if (CurrentContext.TargetComp.IsValid())
{
DisplayName = CurrentContext.TargetComp->GetInteractableData().DisplayName;
}
// 执行案 §跨系统交互:拾取通知("找到了 XXX")归物品系统控制,不在交互系统执行案范围。
// P-016 范围:仅提供表现能力,v0.1 由交互系统 EffectManager 先行接通(物品系统 AddToBackpack
// 接通属于 P-013)。若 Widget Class 已注入则显示,否则日志占位。
const FString NotificationText = DisplayName.IsEmpty()
? TEXT("找到了东西!")
: FString::Printf(TEXT("找到了 %s!"), *DisplayName);
if (PickupWidgetClass && CurrentContext.Instigator.IsValid())
{
if (APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController()))
{
RemovePickupWidget();
UPickupNotificationWidget* Widget = CreateWidget<UPickupNotificationWidget>(PC, PickupWidgetClass);
if (Widget)
{
Widget->AddToViewport();
Widget->SetPickupText(NotificationText);
Widget->Show();
CachedPickupWidget = Widget;
// 自动淡出(执行案要求通知短暂停留后消失),2.0s 后 Hide
if (UWorld* World = Widget->GetWorld())
{
TWeakObjectPtr<UPickupNotificationWidget> WeakWidget = Widget;
FTimerHandle AutoHideHandle;
World->GetTimerManager().SetTimer(AutoHideHandle, [WeakWidget]()
{
if (WeakWidget.IsValid())
{
WeakWidget->Hide();
}
}, 2.0f, false);
}
UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowPickupNotification (Widget) - %s"),
*NotificationText);
}
}
}
else
{
UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowPickupNotification (日志占位,WBP_PickupNotification 未配置) - %s"),
*NotificationText);
}
}
void FEffectManager::ExecuteToggleDoor()
{
if (!CurrentContext.TargetComp.IsValid()) return;
if (UDoorInteractableComponent* DoorComp = Cast<UDoorInteractableComponent>(CurrentContext.TargetComp.Get()))
{
DoorComp->ToggleDoor();
UE_LOG(LogTemp, Log, TEXT("EffectManager: ToggleDoor executed"));
}
}
// ============================================================================
// 表现层 Widget 辅助(P-016)
// ============================================================================
void FEffectManager::RemoveNarrativeWidget()
{
if (CachedNarrativeWidget.IsValid())
{
UNarrativeTextWidget* Widget = CachedNarrativeWidget.Get();
CachedNarrativeWidget = nullptr;
// 强制移除并清理(PerformCleanup / 下次 ShowNarrative 调用)
// 若 Widget 正在播放淡出动画,RemoveFromParent 会中断它,这是兜底清理的可接受行为
if (Widget && Widget->IsInViewport())
{
Widget->RemoveFromParent();
}
}
}
void FEffectManager::RemovePickupWidget()
{
if (CachedPickupWidget.IsValid())
{
UPickupNotificationWidget* Widget = CachedPickupWidget.Get();
CachedPickupWidget = nullptr;
if (Widget && Widget->IsInViewport())
{
Widget->RemoveFromParent();
}
}
}
// ============================================================================
// TurnToTarget 插值
// ============================================================================
void FEffectManager::UpdateTurn(float DeltaTime)
{
if (!TurnState.bIsActive) return;
TurnState.ElapsedTime += DeltaTime;
if (TurnState.IsComplete())
{
if (CurrentContext.Instigator.IsValid())
{
CurrentContext.Instigator.Get()->SetActorRotation(TurnState.TargetRotation);
}
TurnState.bIsActive = false;
return;
}
float Alpha = TurnState.GetAlpha();
FRotator NewRot = FMath::Lerp(TurnState.StartRotation, TurnState.TargetRotation, Alpha);
if (CurrentContext.Instigator.IsValid())
{
CurrentContext.Instigator.Get()->SetActorRotation(NewRot);
}
}
// ============================================================================
// 辅助方法
// ============================================================================
FString FEffectManager::PickRandomNarrative(const TArray<FString>& Texts)
{
if (Texts.Num() == 0) return TEXT("");
if (Texts.Num() == 1) return Texts[0];
int32 CandidateIndex;
do
{
CandidateIndex = FMath::RandRange(0, Texts.Num() - 1);
} while (CandidateIndex == LastNarrativeIndex && Texts.Num() > 1);
LastNarrativeIndex = CandidateIndex;
return Texts[CandidateIndex];
}
@@ -1,169 +0,0 @@
// ChildHood Project - Effect Timeline 调度器(v1.1 Effect/Action 架构)
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)+ 第6节(复杂接口描述)
// 职责:Effect Timeline 调度、Action 执行分发、TurnToTarget 插值、WaitForInput 暂停机制
// 注意:普通 C++ 类,非 UObject,由 ASystemDriver 持有并 Tick 驱动
#pragma once
#include "CoreMinimal.h"
#include "InteractionTypes.h"
#include "Templates/SubclassOf.h"
class APushCameraActor;
class UNarrativeTextWidget;
class UPickupNotificationWidget;
/**
* FEffectManager
*
* Effect Timeline 调度器,管理一次交互 Effect 的完整生命周期:
* - 按 TriggerTime 依次触发各 Action
* - 维护 TurnToTarget 跨帧插值
* - 支持 WaitForInput 暂停/恢复机制
* - 支持取消(CancelEffect)
* - Effect 结束时通过 OnEffectComplete 回调通知上层
*
* 生命周期:由 ASystemDriver 在 BeginPlay 中构造,每帧由 ASystemDriver::Tick 调用 Update。
*/
class FEffectManager
{
public:
// === 公开接口 ===
/** 设置完成回调(由 ASystemDriver 在 BeginPlay 中设置) */
void SetOnEffectComplete(FOnEffectComplete&& Callback);
/** 设置物品获得回调(AddToBackpack Action 执行时触发)。
* 由 ASystemDriver 绑定到 FCollectionManager::OnItemObtained(收集系统技术方案第13节)。 */
void SetOnItemAddedCallback(FOnItemAdded&& Callback);
/**
* 设置表现层 Widget 蓝图类(由 ASystemDriver 在 BeginPlay 中注入)。
* FEffectManager 是普通 C++ 类,不能持有 UPROPERTY,故 Widget Class 的编辑器配置点
* 放在 ASystemDriver 上(TSubclassOf UPROPERTY),运行时通过本接口注入。
* UClass 为全局单例永久存活,普通 TSubclassOf 引用安全。
*
* P-016:接通 ShowNarrative / HideNarrative / ShowPickupNotification 的真实 Widget 显示。
*
* @param NarrativeClass WBP_NarrativeText 蓝图类(nullptr 时回退日志占位)
* @param PickupClass WBP_PickupNotification 蓝图类(nullptr 时回退日志占位)
*/
void SetWidgetClasses(TSubclassOf<UNarrativeTextWidget> NarrativeClass,
TSubclassOf<UPickupNotificationWidget> PickupClass);
/** 播放 Effect — 初始化状态,开始 Timeline 推进 */
void PlayEffect(const FInteractEffect& Effect, const FEffectContext& Context);
/** 取消 Effect(用户按 E/ESC) — 区分 WaitForInput 恢复 vs 强制取消 */
void CancelEffect();
/** 当前是否正在播放 Effect */
bool IsPlaying() const { return bIsPlaying; }
/** 当前是否暂停等待用户输入 */
bool IsWaitingForInput() const { return bIsWaitingForInput; }
/** 每帧更新(由 ASystemDriver::Tick 调用) */
void Update(float DeltaTime);
private:
// === Effect 状态 ===
/** 当前播放中的 Effect 数据 */
FInteractEffect CurrentEffect;
/** 当前 Effect 的运行时上下文 */
FEffectContext CurrentContext;
/** 从 Effect 开始累计的经过时间 */
float ElapsedTime = 0.f;
/** 下一个待触发的 Action 索引(Actions 按 TriggerTime 排序) */
int32 NextActionIndex = 0;
/** 是否正在播放 Effect */
bool bIsPlaying = false;
/** 是否暂停等待用户输入(ShowNarrative bWaitForInput=true 时设置) */
bool bIsWaitingForInput = false;
/** 上一次随机选取的叙事文本索引(用于避免连续重复) */
int32 LastNarrativeIndex = -1;
// === Turn 插值 ===
/** TurnToTarget 跨帧插值状态 */
FTurnState TurnState;
// === 推镜缓存 ===
/** 推镜期间 Spawn 的临时摄像机(弱引用,Effect 结束时销毁) */
TWeakObjectPtr<APushCameraActor> CachedPushCamera;
// === 表现层 Widget 缓存(P-016 接通真实 UI)===
/** NarrativeText Widget 蓝图类(由 ASystemDriver 注入,nullptr 时回退日志占位) */
TSubclassOf<UNarrativeTextWidget> NarrativeWidgetClass;
/** PickupNotification Widget 蓝图类(由 ASystemDriver 注入,nullptr 时回退日志占位) */
TSubclassOf<UPickupNotificationWidget> PickupWidgetClass;
/** 当前显示中的叙事文本 Widget(弱引用,HideNarrative / PerformCleanup 时淡出移除) */
TWeakObjectPtr<UNarrativeTextWidget> CachedNarrativeWidget;
/** 当前显示中的拾取通知 Widget(弱引用) */
TWeakObjectPtr<UPickupNotificationWidget> CachedPickupWidget;
// === 回调 ===
/** Effect 完成回调(正常结束或取消后清理完毕时触发) */
FOnEffectComplete OnEffectComplete;
/** 物品获得回调(AddToBackpack Action 触发,传 ItemId/DisplayName/Category) */
FOnItemAdded OnItemAdded;
// === 内部方法 ===
/** 执行单个 Action(按 Type 分发到对应 ExecuteXxx) */
void ExecuteAction(const FInteractAction& Action);
/** Effect 正常结束 — 清理状态 + 广播回调 */
void FinishEffect();
/** 从 WaitForInput 暂停恢复 */
void ResumeFromWait();
/** 清理所有运行时状态(推镜相机、蒙太奇、转向等) */
void PerformCleanup();
/** 从 NarrativeTexts 中随机选取一条(避免与上次重复) */
FString PickRandomNarrative(const TArray<FString>& Texts);
// === 各 Action 处理 ===
void ExecuteWait(const FInteractAction& Action);
void ExecuteTurnToTarget(const FInteractAction& Action);
void ExecutePlayMontage(const FInteractAction& Action);
void ExecuteCameraPush(const FInteractAction& Action);
void ExecuteCameraReturn(const FInteractAction& Action);
void ExecuteConsume();
void ExecuteHideActor();
void ExecuteShowActor();
void ExecuteShowNarrative(const FInteractAction& Action);
void ExecuteHideNarrative();
void ExecuteShowPickupNotification();
void ExecuteToggleDoor();
// === 表现层 Widget 辅助(P-016)===
/** 移除并清理缓存的 Narrative Widget(若存在则触发淡出,超时强制移除) */
void RemoveNarrativeWidget();
/** 移除并清理缓存的 Pickup Widget(若存在) */
void RemovePickupWidget();
/** 更新 TurnToTarget 跨帧插值(在 Update 末尾调用) */
void UpdateTurn(float DeltaTime);
};
@@ -1,4 +1,4 @@
// ChildHood Project - 通用交互物 Actor 基类 实现(v1.1 Effect/Action 架构)
// ChildHood Project - 通用交互物 Actor 基类 实现
#include "Interaction/InteractableActor.h"
#include "Interaction/InteractableComponent.h"
@@ -18,7 +18,8 @@ AInteractableActor::AInteractableActor()
RootComponent = MeshComp;
MeshComp->SetCollisionProfileName(TEXT("BlockAll"));
// InteractableComp 由子类创建(本类为 abstract)
// 创建交互组件(UActorComponent 不需要 SetupAttachment,数据驱动型组件)
InteractableComp = CreateDefaultSubobject<UInteractableComponent>(TEXT("InteractableComp"));
}
// ============================================================================
@@ -27,7 +28,17 @@ AInteractableActor::AInteractableActor()
void AInteractableActor::OnInteractStarted_Implementation(UInteractableComponent* Comp)
{
// 默认空实现,子类可覆盖(如 ADoorActor 旋转 Mesh)
if (!Comp || !MeshComp) return;
const FInteractableData& Data = Comp->GetInteractableData();
// 门类型:根据开关状态旋转 Mesh
if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door)
{
FRotator CurrentRot = MeshComp->GetRelativeRotation();
float TargetYaw = Comp->IsDoorOpen() ? 90.0f : 0.0f;
MeshComp->SetRelativeRotation(FRotator(CurrentRot.Pitch, TargetYaw, CurrentRot.Roll));
}
}
void AInteractableActor::OnInteractEnded_Implementation(UInteractableComponent* Comp)
@@ -1,7 +1,6 @@
// ChildHood Project - 通用交互物 Actor 基类(v1.1 Effect/Action 架构)
// ChildHood Project - 通用交互物 Actor 基类
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:持有 Mesh,提供可被子类覆盖的交互回调
// 注意:本类为 abstract,子类必须自行创建并赋值 InteractableComp
// 职责:持有 Mesh + InteractableComponent,提供可被子类覆盖的交互回调
#pragma once
@@ -10,19 +9,21 @@
#include "InteractableActor.generated.h"
class UInteractableComponent;
class UStaticMeshComponent;
/**
* AInteractableActor
*
* 交互物 Actor 抽象基类。提供 Mesh 和交互回调框架。
* 子类必须在构造函数中创建合适的 InteractableComponent 并赋值给 InteractableComp。
* 通用交互物 Actor 基类。场景中的每个交互物(冰箱、门、NPC 等)
* 均为该类的实例或其子类的实例。
*
* 组成:
* - UStaticMeshComponent* MeshComp(根组件,承载可视模型)
* - UInteractableComponent* InteractableComp(由子类创建)
* - UInteractableComponent* InteractableComp(交互逻辑与数据)
*
* 子类可覆盖 OnInteractStarted / OnInteractEnded 以实现类型特定行为,
* 如门开关时的 Mesh 旋转、拾取物的消失效果等。
*/
UCLASS(abstract)
UCLASS()
class AInteractableActor : public AActor
{
GENERATED_BODY()
@@ -44,20 +45,15 @@ public:
// === 可被子类覆盖的交互回调 ===
/**
* 交互开始时调用。
* BlueprintNativeEvent,子类可在蓝图中或 C++ 中覆盖实现。
* 默认为空实现。
* 交互开始时调用(门开关切换、状态恢复时也调用)。
* 默认实现:门类型根据开关状态对 Mesh 做 Yaw 旋转(开门+90度/关门0度)。
*/
UFUNCTION(BlueprintNativeEvent, Category = "Interact", meta = (DisplayName = "On Interact Started"))
UFUNCTION(BlueprintNativeEvent, Category = "Interact")
void OnInteractStarted(UInteractableComponent* Comp);
virtual void OnInteractStarted_Implementation(UInteractableComponent* Comp);
/**
* 交互结束时调用。
* BlueprintNativeEvent,子类可在蓝图中或 C++ 中覆盖实现。
* 默认为空实现。
*/
UFUNCTION(BlueprintNativeEvent, Category = "Interact", meta = (DisplayName = "On Interact Ended"))
/** 交互结束时调用。默认为空实现,子类可覆盖。 */
UFUNCTION(BlueprintNativeEvent, Category = "Interact")
void OnInteractEnded(UInteractableComponent* Comp);
virtual void OnInteractEnded_Implementation(UInteractableComponent* Comp);
@@ -65,12 +61,9 @@ protected:
/** 交互物的可视模型 */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UStaticMeshComponent> MeshComp;
UStaticMeshComponent* MeshComp;
/**
* 交互物的交互组件(数据 + 检测 + 状态)。
* 由子类在构造函数中创建并赋值。
*/
/** 交互物的交互组件(数据 + 检测 + 状态) */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UInteractableComponent> InteractableComp;
UInteractableComponent* InteractableComp;
};
@@ -1,16 +1,15 @@
// ChildHood Project - 可交互物组件 实现(v1.1 Effect/Action 架构)
// ChildHood Project - 可交互物组件 实现
#include "Interaction/InteractableComponent.h"
#include "Interaction/InteractableActor.h"
#include "Interaction/PersistenceSubsystem.h"
#include "SystemDriver.h"
#include "Interaction/InteractionManager.h"
#include "ChildHoodGameInstance.h"
#include "ChildHood.h"
#include "Components/SphereComponent.h"
#include "Engine/World.h"
#include "Engine/GameInstance.h"
#include "GameFramework/Character.h"
#include "Kismet/GameplayStatics.h"
// ============================================================================
// 构造函数
@@ -37,7 +36,7 @@ void UInteractableComponent::BeginPlay()
return;
}
// --- 创建检测球(BeginPlay 中动态创建,因为需要 Data.TriggerRadius)---
// --- 创建检测球 ---
DetectionSphere = NewObject<USphereComponent>(this, USphereComponent::StaticClass(), TEXT("DetectionSphere"));
if (DetectionSphere)
{
@@ -59,15 +58,14 @@ void UInteractableComponent::BeginPlay()
}
// --- 恢复跨场景持久状态 ---
UGameInstance* GI = Owner->GetGameInstance();
if (UPersistenceSubsystem* Persistence = GI ? GI->GetSubsystem<UPersistenceSubsystem>() : nullptr)
if (UChildHoodGameInstance* GI = GetWorld()->GetGameInstance<UChildHoodGameInstance>())
{
RestorePersistentState(Persistence);
RestorePersistentState(GI);
}
UE_LOG(LogChildHood, Log, TEXT("InteractableComponent Init: %s (ItemId=%s, Radius=%.0f)"),
UE_LOG(LogChildHood, Log, TEXT("InteractableComponent Init: %s (Type=%s, Radius=%f)"),
Data.DisplayName.IsEmpty() ? TEXT("(unnamed)") : *Data.DisplayName,
*Data.ItemId.ToString(),
*UEnum::GetValueAsString(Data.Type),
Data.TriggerRadius);
}
@@ -75,16 +73,22 @@ void UInteractableComponent::BeginPlay()
// 提示文本
// ============================================================================
FString UInteractableComponent::GetPromptText_Implementation() const
FString UInteractableComponent::GetPromptText() const
{
return FString::Printf(TEXT("[E] %s %s"), *Data.InteractVerb, *Data.DisplayName);
FString Verb = Data.InteractVerb;
// 门类型:根据当前开关状态选择动词
if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door)
{
Verb = bDoorIsOpen ? Data.InteractVerbAlt : Data.InteractVerb;
}
return FString::Printf(TEXT("[E] %s %s"), *Verb, *Data.DisplayName);
}
// ============================================================================
// 消耗管理
// ============================================================================
void UInteractableComponent::Consume_Implementation()
void UInteractableComponent::Consume()
{
bIsConsumed = true;
@@ -95,17 +99,29 @@ void UInteractableComponent::Consume_Implementation()
}
// 写入持久层
if (UGameInstance* GI = GetOwner() ? GetOwner()->GetGameInstance() : nullptr)
if (UWorld* World = GetWorld())
{
if (UPersistenceSubsystem* Persistence = GI->GetSubsystem<UPersistenceSubsystem>())
if (UChildHoodGameInstance* GI = World->GetGameInstance<UChildHoodGameInstance>())
{
Persistence->MarkInteractableConsumed(Data.ItemId);
GI->MarkItemCollected(Data.ItemId);
}
}
UE_LOG(LogChildHood, Log, TEXT("InteractableComponent 已消耗: %s"), *Data.ItemId.ToString());
}
// ============================================================================
// 门状态管理
// ============================================================================
void UInteractableComponent::ToggleDoor()
{
bDoorIsOpen = !bDoorIsOpen;
UE_LOG(LogChildHood, Log, TEXT("InteractableComponent 门状态切换: %s → %s"),
*Data.ItemId.ToString(),
bDoorIsOpen ? TEXT("开") : TEXT("关"));
}
// ============================================================================
// 位置查询
// ============================================================================
@@ -120,24 +136,38 @@ FVector UInteractableComponent::GetWorldLocation() const
// 跨场景持久状态恢复
// ============================================================================
void UInteractableComponent::RestorePersistentState(UPersistenceSubsystem* Persistence)
void UInteractableComponent::RestorePersistentState(UChildHoodGameInstance* GI)
{
if (!Persistence) return;
if (!GI) return;
// oneShot 物品:已消耗 → 隐藏 + 禁用检测
if (Data.bOneShot && Persistence->IsInteractableConsumed(Data.ItemId))
if (Data.bOneShot && GI->IsItemCollected(Data.ItemId))
{
bIsConsumed = true;
if (DetectionSphere)
{
DetectionSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
if (AActor* Owner = GetOwner())
if (AInteractableActor* Owner = Cast<AInteractableActor>(GetOwner()))
{
Owner->SetActorHiddenInGame(true);
}
UE_LOG(LogChildHood, Log, TEXT("RestorePersistentState: 已消耗物品隐藏: %s"), *Data.ItemId.ToString());
}
// 门:恢复开关状态
if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door)
{
bDoorIsOpen = GI->IsDoorOpen(Data.ItemId);
// 通知 Actor 应用视觉状态(Mesh 旋转)
if (AInteractableActor* Owner = Cast<AInteractableActor>(GetOwner()))
{
Owner->OnInteractStarted(this);
}
UE_LOG(LogChildHood, Log, TEXT("RestorePersistentState: 门状态恢复: %s → %s"),
*Data.ItemId.ToString(),
bDoorIsOpen ? TEXT("开") : TEXT("关"));
}
}
// ============================================================================
@@ -163,14 +193,10 @@ void UInteractableComponent::OnDetectionOverlapBegin(UPrimitiveComponent* Overla
UE_LOG(LogChildHood, Verbose, TEXT("检测到角色进入范围: %s ← %s"),
*Data.DisplayName, *OtherActor->GetName());
// 通过 ASystemDriver 注册到 InteractionManager
if (ASystemDriver* Driver = ASystemDriver::Get(this))
// 获取角色的 InteractionManager 并注册自己
if (UInteractionManager* IM = Character->FindComponentByClass<UInteractionManager>())
{
Driver->GetInteractionManager().RegisterInteractable(this);
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("InteractableComponent: ASystemDriver 不存在,无法注册 %s"), *Data.DisplayName);
IM->RegisterInteractable(this);
}
}
@@ -187,9 +213,9 @@ void UInteractableComponent::OnDetectionOverlapEnd(UPrimitiveComponent* Overlapp
UE_LOG(LogChildHood, Verbose, TEXT("角色离开范围: %s ← %s"),
*Data.DisplayName, *OtherActor->GetName());
// 通过 ASystemDriver 从 InteractionManager 注销
if (ASystemDriver* Driver = ASystemDriver::Get(this))
// 获取角色的 InteractionManager 并注销自己
if (UInteractionManager* IM = Character->FindComponentByClass<UInteractionManager>())
{
Driver->GetInteractionManager().UnregisterInteractable(this);
IM->UnregisterInteractable(this);
}
}
@@ -1,7 +1,6 @@
// ChildHood Project - 可交互物组件(v1.1 Effect/Action 架构)
// ChildHood Project - 可交互物组件
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)+ 第12节(跨场景持久化)
// 职责:单个交互物配置数据、触发区域、运行时状态、跨场景持久状态恢复
// 核心变更(v0.1 → v1.1):去除所有门逻辑,只保留通用交互物功能
#pragma once
@@ -10,21 +9,15 @@
#include "InteractionTypes.h"
#include "InteractableComponent.generated.h"
class UPersistenceSubsystem;
class UChildHoodGameInstance;
class USphereComponent;
class ASystemDriver;
/**
* UInteractableComponent
*
* 挂载在 AInteractableActor(或其子类)上的组件。
* 持有该交互物的全部配置数据(FInteractableData)、运行时触发检测球、
* 消耗状态管理,以及跨场景持久状态恢复。
*
* 碰撞回调通过 ASystemDriver::Get() 获取 Driver,
* 再调用 GetInteractionManager().RegisterInteractable(this) 注册。
*
* 门逻辑已移除,由 UDoorInteractableComponent 派生类实现(Phase 2)。
* 消耗/门状态管理,以及跨场景持久状态恢复。
*/
UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent))
class UInteractableComponent : public UActorComponent
@@ -48,31 +41,29 @@ public:
/**
* 获取提示文本,格式:"[E] 动作词 名称"
* 子类可覆盖以实现动态动词(如门的"打开"/"关上")
* 门类型根据当前开关状态选择动词
*/
UFUNCTION(BlueprintNativeEvent, Category = "Interact", meta = (DisplayName = "Get Prompt Text"))
FString GetPromptText() const;
virtual FString GetPromptText_Implementation() const;
/** 该交互物是否已被消耗(oneShot 模式) */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Interact")
bool IsConsumed() const { return bIsConsumed; }
/** 标记为已消耗,禁用检测球 */
UFUNCTION(BlueprintNativeEvent, Category = "Interact", meta = (DisplayName = "Consume"))
/** 标记为已消耗,禁用检测球,写入持久层 */
void Consume();
virtual void Consume_Implementation();
// === 门状态管理 ===
/** 切换门开关状态 */
void ToggleDoor();
/** 门是否处于打开状态 */
bool IsDoorOpen() const { return bDoorIsOpen; }
/** 获取交互物在世界空间的位置 */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Interact")
FVector GetWorldLocation() const;
/**
* 从持久层恢复跨场景持久状态(BeginPlay 中调用)。
* oneShot 物品已消耗时隐藏 Actor 并禁用检测。
* 子类可覆盖以恢复额外状态(如门的开关状态)。
*/
virtual void RestorePersistentState(UPersistenceSubsystem* Persistence);
/** 从 GameInstance 恢复跨场景持久状态(BeginPlay 中调用) */
void RestorePersistentState(UChildHoodGameInstance* GI);
protected:
@@ -82,14 +73,17 @@ private:
/** 运行时创建的检测球,用于触发玩家进入/离开交互范围 */
UPROPERTY()
TObjectPtr<USphereComponent> DetectionSphere;
USphereComponent* DetectionSphere;
/** oneShot 物品是否已被消耗 */
bool bIsConsumed = false;
/** 门是否处于打开状态(仅 Operate+Door 类型使用) */
bool bDoorIsOpen = false;
// === 碰撞回调 ===
/** 玩家进入检测范围 — 通过 ASystemDriver 注册到 InteractionManager */
/** 玩家进入检测范围 */
UFUNCTION()
void OnDetectionOverlapBegin(UPrimitiveComponent* OverlappedComponent,
AActor* OtherActor,
@@ -98,7 +92,7 @@ private:
bool bFromSweep,
const FHitResult& SweepResult);
/** 玩家离开检测范围 — 通过 ASystemDriver 从 InteractionManager 注销 */
/** 玩家离开检测范围 */
UFUNCTION()
void OnDetectionOverlapEnd(UPrimitiveComponent* OverlappedComponent,
AActor* OtherActor,
File diff suppressed because it is too large Load Diff
+177 -87
View File
@@ -1,135 +1,225 @@
// ChildHood Project - 交互管理器(v1.1 Effect/Action 架构重写)
// ChildHood Project - 交互管理器核心组件
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)+ 第6节(行为规则实现)
// 职责:检测列表维护、选中管理、顶层状态(Free/Executing)
// 架构:普通 C++ 类(非 UObject、非 UActorComponent),由 ASystemDriver 值持有
// 特点:纯事件驱动无 Tick,所有变化由外部调用触发
// 职责:交互全流程状态机、检测选择、类型分发、摄像机/转向/UI 控制
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InteractionTypes.h"
#include "InteractionManager.generated.h"
class UInteractableComponent;
class ACharacter;
class APushCameraActor;
class AChildHoodCharacter;
class UInteractionPromptWidget;
class UNarrativeTextWidget;
/**
* FInteractionManager
* UInteractionManager
*
* 普通C++类,负责交互检测列表维护和顶层状态管理。
* 由 ASystemDriver 值持有,不参与 UE 反射系统。
*
* 数据流:
* InteractableComponent 碰撞回调 → ASystemDriver → RegisterInteractable/UnregisterInteractable
* Character 输入回调 → ASystemDriver → BeginInteract/CancelInteract/SwitchTarget
* EffectManager 完成回调 → ASystemDriver → OnInteractionFinished
* 挂载在 AChildHoodCharacter 上的组件,负责交互全流程管理:
* - 层级状态机驱动(Free / Interacting + 子阶段)
* - 附近交互物检测列表排序与选中管理
* - 四种交互类型分发(观察/拾取/操作/对话)
* - 角色自动转向插值
* - 摄像机推镜/回位控制
* - UI 提示与文本显示控制(v0.1 先用日志)
*/
class FInteractionManager
UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent))
class UInteractionManager : public UActorComponent
{
GENERATED_BODY()
public:
/** 初始化管理器,绑定玩家角色引用 */
void Initialize(ACharacter* PlayerCharacter);
UInteractionManager();
// === 交互物注册(由 InteractableComponent 碰撞回调通过 ASystemDriver 调用)===
// === 公开方法 ===
/**
* 注册交互物到检测列表。
* 添加后按水平距离排序,重置选中索引为0,刷新缓存。
*/
void RegisterInteractable(UInteractableComponent* Interactable);
/** 发起交互(E键触发),前置检查通过后进入 Enter 阶段 */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void BeginInteract();
/**
* 从检测列表注销交互物。
* Executing 状态下如果移除的是 CurrentInteractable,仅从列表移除,
* CurrentInteractable 延迟清理(因为 Effect 可能仍在执行)。
*/
void UnregisterInteractable(UInteractableComponent* Interactable);
// === 交互触发(Character 输入回调中调用)===
/**
* 发起交互(E键触发)。
* 前置检查:State==Free、SelectedIndex有效、!IsConsumed()。
* 成功后 State=Executing,返回选中的 Component。
* 拒绝则返回 nullptr。
*/
UInteractableComponent* BeginInteract();
/**
* 取消交互(ESC/E键在Executing阶段触发)。
* v0.1 简化:仅设置状态标记,实际取消由 ASystemDriver 协调 EffectManager。
*/
/** 取消/结束交互(ESC/E键在Active阶段触发),AnimLock期间不可取消 */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void CancelInteract();
/**
* 切换选中目标(Tab/滚轮)。
* Direction: +1下一个 / -1上一个,循环。
* 仅在 Free 状态下响应。
*/
/** 切换选中交互物(Tab/滚轮),Direction: +1下一个 / -1上一个 */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void SwitchTarget(int32 Direction);
// === 状态查询 ===
/** 注册交互物到检测列表(由 InteractableComponent 的碰撞回调触发) */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void RegisterInteractable(UInteractableComponent* Interactable);
/** 获取当前顶层状态 */
EInteractionState GetState() const { return State; }
/** 从检测列表注销交互物(离开范围或消耗时触发) */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void UnregisterInteractable(UInteractableComponent* Interactable);
/** 当前是否有有效的选中交互物 */
bool HasSelection() const { return CachedSelected.bHasSelection; }
// === 委托 ===
/** 获取选中交互物的提示文本(从缓存读取,不访问 Component) */
FString GetSelectedPromptText() const { return CachedSelected.PromptText; }
/** 物品拾取事件 — 通知物品系统(Pickup 类型 ExecuteAnimEvent 中广播) */
UPROPERTY(BlueprintAssignable, Category = "Interaction|Events")
FMLocalItemEvent OnItemPickedUp;
/** 获取选中交互物的世界坐标(从缓存读取) */
FVector GetSelectedWorldPosition() const { return CachedSelected.WorldPosition; }
/** 物品获得事件 — 通知收集系统(Search 类型 OnAnimLockComplete 中广播) */
UPROPERTY(BlueprintAssignable, Category = "Interaction|Events")
FMLocalItemEvent OnItemFound;
/** 获取完整的选中信息缓存 */
const FSelectedInfo& GetCachedSelectedInfo() const { return CachedSelected; }
// === 可调参数 ===
// === Effect 完成回调(由 EffectManager 通过 ASystemDriver 调用)===
/** 角色转向时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Tuning")
float TurnDuration = 0.2f;
/**
* Effect 执行完毕回调。
* State 回到 Free,清理 CurrentInteractable,通知 Character 解除交互,
* 重新排序和刷新缓存(因为消耗可能改变了列表)。
*/
void OnInteractionFinished();
/** 提示 UI 淡入时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Tuning")
float PromptFadeInDuration = 0.2f;
// === 当前交互物访问(EffectManager 需要访问)===
/** 提示 UI 淡出时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Tuning")
float PromptFadeOutDuration = 0.2f;
/** 获取当前正在交互的交互物(Executing 状态下有效) */
UInteractableComponent* GetCurrentInteractable() const;
protected:
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
private:
// === 状态机 ===
/** 交互系统顶层状态 */
EInteractionState State = EInteractionState::Free;
/** 当前附近的交互物列表,按水平距离排序 */
TArray<TWeakObjectPtr<UInteractableComponent>> NearbyInteractables;
/** Interacting 子阶段 */
EInteractPhase Phase = EInteractPhase::None;
/** 当前选中的交互物索引(NearbyInteractables 中的下标) */
// === 检测与选择 ===
/** 当前附近的交互物列表,按水平距离排序 */
UPROPERTY()
TArray<UInteractableComponent*> NearbyInteractables;
/** 当前选中的交互物索引 */
int32 SelectedIndex = 0;
/** 当前正在交互的交互物(Executing 状态下锁定) */
TWeakObjectPtr<UInteractableComponent> CurrentInteractable;
/** 当前正在交互的交互物(交互过程中锁定) */
UPROPERTY()
UInteractableComponent* CurrentInteractable = nullptr;
/** 玩家角色弱引用(用于按水平距离排序的选中计算) */
TWeakObjectPtr<ACharacter> PlayerCharacter;
// === 摄像机控制 ===
/** Presentation 层缓存数据 — 选中交互物的摘要信息 */
FSelectedInfo CachedSelected;
/** 推镜用临时摄像机 Actor(BeginPlay 中 Spawn) */
UPROPERTY()
TObjectPtr<APushCameraActor> PushCameraActor;
/** 推镜是否已发起(Enter阶段一次性标志) */
bool bPushStarted = false;
// === 计时器 ===
/** 转向已用时间 */
float TurnElapsed = 0.0f;
/** 推镜后等待已用时间 */
float WaitElapsed = 0.0f;
/** 动画事件计时器(如60%时物品消失) */
FTimerHandle AnimEventTimerHandle;
/** 动画结束计时器 */
FTimerHandle AnimEndTimerHandle;
/** 退出阶段回位计时器 */
FTimerHandle ExitTimerHandle;
/** 动画事件是否已触发 */
bool bAnimEventFired = false;
// === UI 配置 ===
/** 提示 UI 蓝图类(需在编辑器中指定为 WBP_InteractionPrompt) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
TSubclassOf<UInteractionPromptWidget> PromptWidgetClass;
/** 文本底框 UI 蓝图类(需在编辑器中指定为 WBP_NarrativeText) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
TSubclassOf<UNarrativeTextWidget> NarrativeWidgetClass;
/** 提示 UI 高度偏移(交互物上方的世界空间 Z 偏移,cm) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
float PromptHeightOffset = 80.0f;
// === UI 实例 ===
/** 交互提示 Widget 实例 */
UPROPERTY()
TObjectPtr<UInteractionPromptWidget> PromptWidget;
/** 文本底框 Widget 实例 */
UPROPERTY()
TObjectPtr<UNarrativeTextWidget> NarrativeWidget;
// === 文本选择 ===
/** 上次显示的叙事文本(用于不重复选取) */
FString LastNarrativeText;
// === 内部方法 ===
/**
* 更新选中交互物:按与玩家的水平距离排序,重置 SelectedIndex=0。
* 同时清理已失效的弱引用。
*/
/** 更新选中交互物(排序后 SelectedIndex = 0) */
void UpdateSelection();
/**
* 从 Component 读取选中项信息并写入 CachedSelected。
* Presentation 层每帧只读 CachedSelected,不直接访问 Component。
*/
void RefreshCachedSelection();
/** 更新提示 UI 内容和位置 */
void UpdatePromptUI();
/** 隐藏提示 UI */
void HidePrompt();
/** 更新提示 UI 的屏幕空间位置(跟随交互物世界位置投影) */
void UpdatePromptScreenPosition(UInteractableComponent* Interactable);
/** Enter 阶段 Tick:转向插值 + 推镜 + 等待 */
void TickEnterPhase(float DeltaTime);
/** AnimLock 阶段 Tick(v0.1 由计时器驱动,此处可为空) */
void TickAnimLockPhase(float DeltaTime);
/** Exit 阶段 Tick(v0.1 由定时器驱动回 Free) */
void TickExitPhase(float DeltaTime);
/** Enter 阶段完成后,根据交互类型分发到下一阶段 */
void TransitionFromEnter();
/** 进入 AnimLock 阶段:播放蒙太奇或启动计时器 */
void EnterAnimLockPhase();
/** 动画事件触发点回调(如拾取物消失、门状态切换) */
void ExecuteAnimEvent();
/** 动画结束回调 */
void OnAnimLockComplete();
/** 显示叙事文本(v0.1 用日志输出) */
void ShowNarrativeText(const FString& Text);
/** 隐藏叙事文本(v0.1 用日志输出) */
void HideNarrativeText();
/** 推镜:移动 PushCameraActor 并切换 ViewTarget */
void PushCamera(FVector Target, float Duration);
/** 相机回位:切换回 Character 的 FollowCamera */
void ReturnCamera(float Duration);
/** 计算推镜目标位置 */
FVector CalculatePushPosition(UInteractableComponent* Interactable);
/** 随机选取叙事文本,排除上次 */
FString PickRandomNarrative(const TArray<FString>& Texts);
/** Exit 阶段定时器回调:回到 Free 状态 */
void OnExitTimerComplete();
};
@@ -1,160 +0,0 @@
// ChildHood Project - 交互提示追踪组件 实现
// 对应技术方案: docs/technical/交互系统-技术方案.md 第6节(提示UI显示/隐藏)
// 职责:Free 状态下追踪 Manager 缓存,更新 Prompt Widget 位置和文本
#include "Interaction/InteractionPromptComponent.h"
#include "Interaction/InteractionPromptWidget.h"
#include "SystemDriver.h"
#include "Interaction/InteractionManager.h"
#include "ChildHood.h"
#include "Engine/World.h"
#include "Blueprint/WidgetLayoutLibrary.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/PlayerController.h"
// ============================================================================
// 构造函数
// ============================================================================
UInteractionPromptComponent::UInteractionPromptComponent()
{
// 启用 Tick,每帧更新 Prompt 位置
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.bStartWithTickEnabled = true;
}
// ============================================================================
// 初始化
// ============================================================================
void UInteractionPromptComponent::Initialize(ASystemDriver* InDriver)
{
Driver = InDriver;
UE_LOG(LogChildHood, Log, TEXT("InteractionPromptComponent: 初始化完成, Driver=%s"),
InDriver ? *InDriver->GetName() : TEXT("nullptr"));
}
// ============================================================================
// 生命周期
// ============================================================================
void UInteractionPromptComponent::BeginPlay()
{
Super::BeginPlay();
// 根据 PromptWidgetClass 创建 Prompt Widget
if (PromptWidgetClass)
{
if (APlayerController* PC = GetWorld()
? GetWorld()->GetFirstPlayerController()
: nullptr)
{
PromptWidget = CreateWidget<UInteractionPromptWidget>(PC, PromptWidgetClass);
if (PromptWidget)
{
PromptWidget->AddToViewport();
PromptWidget->SetVisibility(ESlateVisibility::Hidden);
UE_LOG(LogChildHood, Log, TEXT("InteractionPromptComponent: Prompt Widget 创建完成"));
}
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("InteractionPromptComponent: 无法获取 PlayerController,Prompt Widget 未创建"));
}
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("InteractionPromptComponent: PromptWidgetClass 未设置,Prompt Widget 未创建"));
}
}
void UInteractionPromptComponent::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// 没有 Widget 则跳过
if (!PromptWidget)
{
return;
}
// 获取 Driver(弱引用检查)
ASystemDriver* DriverPtr = Driver.Get();
if (!DriverPtr)
{
HidePrompt();
return;
}
// 获取 InteractionManager 引用
FInteractionManager& Manager = DriverPtr->GetInteractionManager();
// 检查 State == Free 且 HasSelection()
if (Manager.GetState() == EInteractionState::Free && Manager.HasSelection())
{
UpdatePrompt();
}
else
{
HidePrompt();
}
}
// ============================================================================
// Prompt 显示/隐藏
// ============================================================================
void UInteractionPromptComponent::UpdatePrompt()
{
ASystemDriver* DriverPtr = Driver.Get();
if (!DriverPtr || !PromptWidget)
{
return;
}
FInteractionManager& Manager = DriverPtr->GetInteractionManager();
// 从 Manager 缓存读取数据
FString PromptText = Manager.GetSelectedPromptText();
FVector WorldPos = Manager.GetSelectedWorldPosition();
// 应用高度偏移
FVector AdjustedPos = WorldPos + FVector(0.f, 0.f, PromptHeightOffset);
// ProjectWorldToScreen
APlayerController* PC = GetWorld()
? GetWorld()->GetFirstPlayerController()
: nullptr;
if (!PC)
{
return;
}
FVector2D ScreenPos;
bool bOnScreen = PC->ProjectWorldLocationToScreen(AdjustedPos, ScreenPos, true);
if (!bOnScreen)
{
HidePrompt();
return;
}
// 更新 Widget 位置和文本
PromptWidget->SetPromptText(PromptText);
PromptWidget->SetScreenPosition(ScreenPos);
PromptWidget->Show();
bWasShowingPrompt = true;
}
void UInteractionPromptComponent::HidePrompt()
{
if (PromptWidget && bWasShowingPrompt)
{
PromptWidget->Hide();
bWasShowingPrompt = false;
}
}
@@ -1,70 +0,0 @@
// ChildHood Project - 交互提示追踪组件
// 对应技术方案: docs/technical/交互系统-技术方案.md 第6节(提示UI显示/隐藏)
// 职责:独立于 Effect 执行,只负责 Free 状态下的 Prompt UI 追踪
// 架构:UActorComponent,自有 Tick,只读 InteractionManager 缓存
// 挂载:Character 上,由 Character::BeginPlay 中调用 Initialize
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InteractionPromptComponent.generated.h"
class ASystemDriver;
class UInteractionPromptWidget;
/**
* UInteractionPromptComponent
*
* 交互提示追踪组件。每帧检查 InteractionManager 的缓存数据,
* 在 Free 状态且有选中交互物时显示 Prompt Widget,Executing 时隐藏。
*
* 数据流:
* InteractionManager 缓存 (CachedSelected) → 此组件 Tick 只读 → Prompt Widget 显示/隐藏
*
* 不直接访问任何 InteractableComponent,所有数据来自 Manager 缓存。
*/
UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent))
class UInteractionPromptComponent : public UActorComponent
{
GENERATED_BODY()
public:
UInteractionPromptComponent();
/** 初始化(Character::BeginPlay 中调用) */
void Initialize(ASystemDriver* InDriver);
protected:
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
private:
/** 弱引用 Driver */
TWeakObjectPtr<ASystemDriver> Driver;
/** Prompt Widget 实例 */
UPROPERTY()
TObjectPtr<UInteractionPromptWidget> PromptWidget;
/** Prompt Widget 蓝图类(编辑器中指定 WBP_InteractionPrompt) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
TSubclassOf<UInteractionPromptWidget> PromptWidgetClass;
/** 提示 UI 高度偏移(cm) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
float PromptHeightOffset = 80.0f;
/** 上一次记录的状态(用于检测状态变化) */
bool bWasShowingPrompt = false;
/** 更新 Prompt 显示 */
void UpdatePrompt();
/** 隐藏 Prompt */
void HidePrompt();
};
+120 -255
View File
@@ -1,5 +1,5 @@
// ChildHood Project - 交互系统数据类型定义(v1.1 Effect/Action 架构)
// 对应技术方案: docs/technical/交互系统-技术方案.md 第5节(核心数据结构)
// ChildHood Project - 交互系统数据类型定义
// 对应技术方案: docs/technical/交互系统-技术方案.md 第5节(核心数据结构)+ 第12节(跨场景持久化)
#pragma once
@@ -10,304 +10,169 @@
// 枚举
// ============================================================================
/** 交互系统顶层状态(简化为 2 阶段) */
/** 交互类型 */
UENUM(BlueprintType)
enum class EInteractType : uint8
{
Examine UMETA(DisplayName = "观察"),
Pickup UMETA(DisplayName = "拾取"),
Operate UMETA(DisplayName = "操作"),
Talk UMETA(DisplayName = "对话")
};
/** 操作子类型(仅 Operate 类型有效) */
UENUM(BlueprintType)
enum class EOperateSubType : uint8
{
Door UMETA(DisplayName = "门(开关切换)"),
Search UMETA(DisplayName = "翻找")
};
/** 交互系统顶层状态 */
UENUM(BlueprintType)
enum class EInteractionState : uint8
{
Free, // 自由状态,检测/选择/Prompt
Executing, // 执行 Effect 中
Free UMETA(DisplayName = "自由状态"),
Interacting UMETA(DisplayName = "交互中"),
SubSystem UMETA(DisplayName = "子系统(v0.1预留)"),
Cutscene UMETA(DisplayName = "过场(v0.1预留)")
};
/** Action 类型 */
/** Interacting 子阶段 */
UENUM(BlueprintType)
enum class EInteractActionType : uint8
enum class EInteractPhase : uint8
{
Wait UMETA(DisplayName = "等待"),
TurnToTarget UMETA(DisplayName = "转向交互物"),
PlayMontage UMETA(DisplayName = "播放人物动画"),
PlayMontageOnTarget UMETA(DisplayName = "播放交互物动画"),
CameraPush UMETA(DisplayName = "相机推镜"),
CameraReturn UMETA(DisplayName = "相机回位"),
Consume UMETA(DisplayName = "消耗交互物"),
HideActor UMETA(DisplayName = "隐藏交互物"),
ShowActor UMETA(DisplayName = "显示交互物"),
AddToBackpack UMETA(DisplayName = "物品进背包"),
ShowNarrative UMETA(DisplayName = "显示叙事文本"),
HideNarrative UMETA(DisplayName = "隐藏叙事文本"),
ShowPickupNotification UMETA(DisplayName = "显示拾取通知"),
ToggleDoor UMETA(DisplayName = "切换门状态"),
PlayDialogue UMETA(DisplayName = "播放对话"),
PlayStory UMETA(DisplayName = "播放小剧情"),
BroadcastEvent UMETA(DisplayName = "广播逻辑事件"),
None UMETA(DisplayName = "无"),
Enter UMETA(DisplayName = "进入(转向+推镜)"),
AnimLock UMETA(DisplayName = "动画锁定"),
Active UMETA(DisplayName = "活跃(等待输入)"),
Exit UMETA(DisplayName = "退出(回位)")
};
/** 交互结束后动作 */
UENUM(BlueprintType)
enum class EPostAction : uint8
{
Return UMETA(DisplayName = "回到Free"),
Minigame UMETA(DisplayName = "小游戏(v0.1预留)"),
Dialogue UMETA(DisplayName = "对话(v0.1预留)"),
SceneChange UMETA(DisplayName = "场景切换(v0.1预留)")
};
// ============================================================================
// 前向声明
// 配置数据结构
// ============================================================================
class UInteractableComponent;
// ============================================================================
// Action — 原子操作定义
// ============================================================================
/** 原子操作定义 — Effect 内按 TriggerTime 编排的最小执行单元 */
USTRUCT(BlueprintType)
struct FInteractAction
{
GENERATED_BODY()
// === 编排 ===
/** Action 类型 */
UPROPERTY(EditAnywhere, Category = "Action")
EInteractActionType Type = EInteractActionType::Wait;
/** 相对于 Effect 开始的触发时间(秒) */
UPROPERTY(EditAnywhere, Category = "Action", meta = (ClampMin = "0"))
float TriggerTime = 0.f;
// === 各类型参数(按 EditCondition 折叠显示) ===
// --- Wait ---
/** 等待时长(秒) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::Wait", EditConditionHides))
float WaitDuration = 0.5f;
// --- TurnToTarget ---
/** 转向时长(秒) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::TurnToTarget", EditConditionHides))
float TurnDuration = 0.2f;
// --- PlayMontage ---
/** 人物蒙太奇动画 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::PlayMontage", EditConditionHides))
TObjectPtr<UAnimMontage> Montage;
// --- PlayMontageOnTarget ---
/** 交互物蒙太奇动画 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::PlayMontageOnTarget", EditConditionHides))
TObjectPtr<UAnimMontage> TargetMontage;
// --- CameraPush ---
/** 推镜距离(cm) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::CameraPush", EditConditionHides))
float PushDistance = 80.f;
/** 推镜过渡时长(秒) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::CameraPush", EditConditionHides))
float PushDuration = 0.5f;
// --- CameraReturn ---
/** 相机回位时长(秒) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::CameraReturn", EditConditionHides))
float ReturnDuration = 0.3f;
// --- ShowNarrative ---
/** 叙事文本列表 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::ShowNarrative", EditConditionHides))
TArray<FString> NarrativeTexts;
/** 是否等待用户按 E/ESC 后再继续后续 Action */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::ShowNarrative", EditConditionHides))
bool bWaitForInput = false;
/** 是否从 NarrativeTexts 中随机选取(不重复上一次) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::ShowNarrative", EditConditionHides))
bool bRandomSelect = true;
};
// ============================================================================
// Effect — 交互效果定义
// ============================================================================
/** Effect 定义 — 由多个 Action 按时间线编排而成的交互效果配方 */
USTRUCT(BlueprintType)
struct FInteractEffect
{
GENERATED_BODY()
/** Effect 名称(调试用) */
UPROPERTY(EditAnywhere, Category = "Effect")
FString EffectName;
/** Effect 总时长(秒),到时自动结束 */
UPROPERTY(EditAnywhere, Category = "Effect", meta = (ClampMin = "0.1", ClampMax = "10.0"))
float TotalDuration = 2.0f;
/** 是否可取消(ESC 中断) */
UPROPERTY(EditAnywhere, Category = "Effect")
bool bCancellable = true;
/** Action 列表 — 按 TriggerTime 编排,同时间并行 */
UPROPERTY(EditAnywhere, Category = "Effect")
TArray<FInteractAction> Actions;
};
// ============================================================================
// 可交互物配置数据
// ============================================================================
/** 可交互物配置数据 — 每个交互物实例独立配置,不再包含类型分发字段 */
/** 可交互物配置数据 — 每个交互物实例独立配置 */
USTRUCT(BlueprintType)
struct FInteractableData
{
GENERATED_BODY()
GENERATED_BODY()
// === 基本信息 ===
// === 基本信息 ===
/** 唯一ID(如 "fridge_home"),用于持久化存储和物品系统对接 */
UPROPERTY(EditAnywhere, Category = "Basic")
FName ItemId;
/** 唯一ID(如 "fridge_home"),用于持久化存储和物品系统对接 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic")
FName ItemId;
/** 显示名称(如 "旧冰箱"),用于提示UI */
UPROPERTY(EditAnywhere, Category = "Basic")
FString DisplayName;
/** 显示名称(如 "旧冰箱"),用于提示UI */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic")
FString DisplayName;
/** 物品品类(如 "杂物"),用于收集系统品类过滤。
* 拾取/翻找物填 "杂物"(收集系统追踪品类),观察物/门等非收集物留空。
* P-013 收集系统通过 AddToBackpack 回调读取此字段做「查品类」。 */
UPROPERTY(EditAnywhere, Category = "Basic")
FName Category;
/** 交互类型 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic")
EInteractType Type = EInteractType::Examine;
// === 触发 ===
/** 操作子类型,仅 Type==Operate 时可见可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic",
meta = (EditCondition = "Type == EInteractType::Operate", EditConditionHides))
EOperateSubType OperateSubType = EOperateSubType::Door;
/** 触发半径(cm),默认1.5m */
UPROPERTY(EditAnywhere, Category = "Trigger", meta = (ClampMin = "50.0", ClampMax = "500.0"))
float TriggerRadius = 150.f;
// === 触发 ===
/** 是否一次性消耗(消耗后不再可交互) */
UPROPERTY(EditAnywhere, Category = "Trigger")
bool bOneShot = false;
/** 触发半径(cm),默认1.5m */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Trigger",
meta = (ClampMin = "50.0", ClampMax = "500.0"))
float TriggerRadius = 150.0f;
// === 提示文本 ===
/** 是否一次性消耗(消耗后不再可交互) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Trigger")
bool bOneShot = false;
/** 动作词(如 "查看"、"拾取"、"打开") */
UPROPERTY(EditAnywhere, Category = "Prompt")
FString InteractVerb;
// === 提示文本 ===
/** 备选动词(如 "关上"),门打开时使用 */
UPROPERTY(EditAnywhere, Category = "Prompt")
FString InteractVerbAlt;
/** 动作词(如 "查看"、"拾取"、"打开") */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Prompt")
FString InteractVerb;
// === 交互 Effect ===
/** 备选动词(如 "关上"),门打开时使用,仅 Operate+Door 时可见 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Prompt",
meta = (EditCondition = "Type == EInteractType::Operate && OperateSubType == EOperateSubType::Door"))
FString InteractVerbAlt;
/** 交互效果定义 — 所有行为由 Effect 内的 Action 定义 */
UPROPERTY(EditAnywhere, Category = "Effect")
FInteractEffect InteractEffect;
// === 叙事/对话文本 ===
// 已删除(由 Effect 内 Action 替代):
// EInteractType Type, EOperateSubType OperateSubType
// bUseCameraPush, CameraPushDistance/Duration, PushWaitDelay, CameraReturnDuration
// InteractMontage, AnimDuration, AnimEventTimepoint
// NarrativeTexts, EPostAction PostAction
};
/** 文本列表,随机选取,不重复上次 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Text")
TArray<FString> NarrativeTexts;
// ============================================================================
// 运行时上下文
// ============================================================================
// === 摄像机推镜 ===
/** Effect 运行时上下文 — EffectManager 执行时传入,持有弱引用 */
USTRUCT()
struct FEffectContext
{
GENERATED_BODY()
/** 是否使用摄像机推镜 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera")
bool bUseCameraPush = false;
/** 触发交互的角色 */
TWeakObjectPtr<ACharacter> Instigator;
/** 推镜距离(cm),仅 bUseCameraPush 时可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera",
meta = (EditCondition = "bUseCameraPush", ClampMin = "10.0", ClampMax = "300.0"))
float CameraPushDistance = 80.0f;
/** 交互物组件(配置数据来源) */
TWeakObjectPtr<UInteractableComponent> TargetComp;
/** 推镜过渡时长(秒),仅 bUseCameraPush 时可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera",
meta = (EditCondition = "bUseCameraPush", ClampMin = "0.1", ClampMax = "2.0"))
float CameraPushDuration = 0.5f;
/** 交互物 Actor(视觉操作目标) */
TWeakObjectPtr<AActor> TargetActor;
};
/** 相机回位时长(秒),仅 bUseCameraPush 时可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera",
meta = (EditCondition = "bUseCameraPush", ClampMin = "0.1", ClampMax = "2.0"))
float CameraReturnDuration = 0.3f;
// ============================================================================
// Presentation 层缓存数据(非 UObject,纯 C++)
// ============================================================================
/** 推镜完成后到文本显示的等待时间(秒),仅 bUseCameraPush 时可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera",
meta = (EditCondition = "bUseCameraPush", ClampMin = "0.0", ClampMax = "1.0"))
float PushWaitDelay = 0.2f;
/** Presentation 层缓存数据 — Manager 在选择变化时从 Component 读一次并写入,
* Prompt 组件每帧只读此缓存,不直接访问 Component */
struct FSelectedInfo
{
/** 是否有有效选中 */
bool bHasSelection = false;
// === 动画 ===
/** 选中交互物的提示文本(如 "[E] 查看旧冰箱") */
FString PromptText;
/** 交互动画蒙太奇(可选,为空时用计时器模拟) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim")
TObjectPtr<UAnimMontage> InteractMontage;
/** 选中交互物的世界坐标(用于屏幕空间投影) */
FVector WorldPosition = FVector::ZeroVector;
/** 无蒙太奇时的动画持续时间(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim",
meta = (ClampMin = "0.1", ClampMax = "5.0"))
float AnimDuration = 1.0f;
/** 选中交互物是否已消耗(消耗后不再显示提示) */
bool bIsConsumed = false;
/** 动画事件触发点(0-1比例),如0.6表示动画播放至60%时触发物品消失 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float AnimEventTimepoint = 0.6f;
void Reset()
{
bHasSelection = false;
PromptText.Empty();
WorldPosition = FVector::ZeroVector;
bIsConsumed = false;
}
};
// === 结束动作 ===
// ============================================================================
// TurnToTarget 跨帧插值状态(非 UObject,纯 C++)
// ============================================================================
/** TurnToTarget 跨帧插值状态 — EffectManager 在 Update 中维护,
* 每帧 RInterpTo 驱动旋转,不能一帧完成 */
struct FTurnState
{
/** 是否正在转向 */
bool bIsActive = false;
/** 转向起始旋转 */
FRotator StartRotation = FRotator::ZeroRotator;
/** 转向目标旋转(面朝交互物方向) */
FRotator TargetRotation = FRotator::ZeroRotator;
/** 已用时间(秒) */
float ElapsedTime = 0.f;
/** 转向总时长(秒) */
float Duration = 0.2f;
/** 是否已完成 */
bool IsComplete() const { return ElapsedTime >= Duration; }
/** 获取当前插值 Alpha [0,1] */
float GetAlpha() const { return FMath::Clamp(ElapsedTime / Duration, 0.f, 1.f); }
void Reset()
{
bIsActive = false;
StartRotation = FRotator::ZeroRotator;
TargetRotation = FRotator::ZeroRotator;
ElapsedTime = 0.f;
Duration = 0.2f;
}
/** 交互结束后的动作 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Post")
EPostAction PostAction = EPostAction::Return;
};
// ============================================================================
// 委托
// ============================================================================
/** 物品消耗事件(逻辑事件,Persistence/Backpack 订阅) */
/** 物品拾取/获得事件 — 通知物品系统和收集系统 */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
FMLocalItemEvent,
FName, ItemId,
const FString&, DisplayName,
const FName&, Category
FMLocalItemEvent,
FName, ItemId,
const FString&, DisplayName,
const FName&, Category
);
/** Effect 完成回调 */
DECLARE_DELEGATE(FOnEffectComplete);
/** 物品获得回调 — AddToBackpack Action 执行时触发,参数 (ItemId, DisplayName, Category)。
* 由 FEffectManager 持有,ASystemDriver 在 BeginPlay 中绑定到 FCollectionManager::OnItemObtained,
* 实现「交互系统 → 收集系统」的解耦连线(收集系统技术方案第13节)。
* 普通委托(非动态多播):纯 C++ lambda 绑定,EffectManager 为普通 C++ 类。 */
DECLARE_DELEGATE_ThreeParams(FOnItemAdded, FName, const FString&, const FName&);
@@ -1,7 +1,6 @@
// ChildHood Project - 文本底框 UI 基类 实现
#include "Interaction/NarrativeTextWidget.h"
#include "Animation/WidgetAnimation.h" // UWidgetAnimation 完整定义(GetEndTime 需要完整类型)
#include "Components/TextBlock.h"
void UNarrativeTextWidget::NativeOnInitialized()
@@ -37,30 +36,18 @@ void UNarrativeTextWidget::Hide()
{
if (!bIsVisible) return;
// 淡出时长从 FadeOutAnim 实际时长读取,消除与动画时长耦合的硬编码
// (美术调整 FadeOutAnim 时长时无需同步改 C++)
const float FadeOutDuration = (FadeOutAnim ? FadeOutAnim->GetEndTime() : 0.2f);
if (FadeOutAnim)
{
PlayAnimation(FadeOutAnim, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f);
}
if (UWorld* World = GetWorld())
{
// 捕获弱引用避免悬空 this(Widget 可能在 Timer 触发前被 RemoveFromParent/GC)
// 注:RemoveFromParent 不在此处执行,而由 EffectManager::RemoveNarrativeWidget 统一负责
// (与 PickupNotificationWidget::Hide() 对称),保证「淡出 → 下次清理 RemoveFromParent」闭环。
TWeakObjectPtr<UNarrativeTextWidget> WeakThis(this);
FTimerHandle TempHandle;
World->GetTimerManager().SetTimer(TempHandle, [WeakThis]()
if (UWorld* World = GetWorld())
{
if (UNarrativeTextWidget* Self = WeakThis.Get())
World->GetTimerManager().SetTimer(TempHandle, [this]()
{
Self->bIsVisible = false;
Self->SetVisibility(ESlateVisibility::Hidden);
}
}, FadeOutDuration, false);
bIsVisible = false;
SetVisibility(ESlateVisibility::Hidden);
}, 0.2f, false);
}
}
else
{
@@ -1,70 +0,0 @@
// ChildHood Project - 跨场景持久化子系统 实现
#include "Interaction/PersistenceSubsystem.h"
#include "ChildHood.h"
// ============================================================================
// 交互物消耗状态
// ============================================================================
bool UPersistenceSubsystem::IsInteractableConsumed(FName InteractableId) const
{
return ConsumedInteractableIDs.Contains(InteractableId);
}
void UPersistenceSubsystem::MarkInteractableConsumed(FName InteractableId)
{
ConsumedInteractableIDs.Add(InteractableId);
UE_LOG(LogChildHood, Log, TEXT("PersistenceSubsystem: 交互物已消耗: %s"), *InteractableId.ToString());
}
// ============================================================================
// 门状态
// ============================================================================
bool UPersistenceSubsystem::IsDoorOpen(FName DoorId) const
{
const bool* Found = DoorStates.Find(DoorId);
return Found ? *Found : false;
}
void UPersistenceSubsystem::SetDoorOpen(FName DoorId, bool bIsOpen)
{
DoorStates.Add(DoorId, bIsOpen);
UE_LOG(LogChildHood, Log, TEXT("PersistenceSubsystem: 门状态更新: %s → %s"),
*DoorId.ToString(), bIsOpen ? TEXT("开") : TEXT("关"));
}
// ============================================================================
// 收集系统状态
// ============================================================================
void UPersistenceSubsystem::GetCollectionState(TSet<FName>& OutIDs, int32& OutProgress, bool& OutComplete) const
{
OutIDs = CollectionCollectedIDs;
OutProgress = CollectionProgress;
OutComplete = bCollectionComplete;
}
void UPersistenceSubsystem::SetCollectionState(const TSet<FName>& InIDs, int32 InProgress, bool bInComplete)
{
CollectionCollectedIDs = InIDs;
CollectionProgress = InProgress;
bCollectionComplete = bInComplete;
UE_LOG(LogChildHood, Log, TEXT("PersistenceSubsystem: 收集状态更新 - 进度 %d, 完成 %s"),
CollectionProgress, bCollectionComplete ? TEXT("是") : TEXT("否"));
}
// ============================================================================
// 重置
// ============================================================================
void UPersistenceSubsystem::ResetAll()
{
ConsumedInteractableIDs.Empty();
DoorStates.Empty();
CollectionCollectedIDs.Empty();
CollectionProgress = 0;
bCollectionComplete = false;
UE_LOG(LogChildHood, Log, TEXT("PersistenceSubsystem: 所有持久数据已重置"));
}
@@ -1,89 +0,0 @@
// ChildHood Project - 跨场景持久化子系统
// 对应技术方案: docs/technical/交互系统-技术方案.md 第12节(跨场景持久化设计)
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "PersistenceSubsystem.generated.h"
/**
* UPersistenceSubsystem
*
* UGameInstanceSubsystem,跨场景持久数据持有者。
* UE5 的 OpenLevel() 会销毁整个旧 World(包括所有 Actor 和 Component),
* 因此将持久数据放在 GameInstanceSubsystem 中,与 World 生命周期解耦。
*
* 当前持有:
* - ConsumedInteractableIDs: 已消耗交互物 ID 集合(一次性交互物防重复触发)
* - DoorStates: 门开关状态映射
*/
UCLASS()
class UPersistenceSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
// === 交互物消耗状态 ===
/** 查询指定交互物是否已被消耗(跨场景持久,一次性交互物防重复触发) */
UFUNCTION(BlueprintCallable, Category = "Persistence")
bool IsInteractableConsumed(FName InteractableId) const;
/** 标记指定交互物为已消耗(写入持久层) */
UFUNCTION(BlueprintCallable, Category = "Persistence")
void MarkInteractableConsumed(FName InteractableId);
// === 门状态 ===
/** 查询指定门的开关状态(跨场景持久),未记录的门默认为关闭 */
UFUNCTION(BlueprintCallable, Category = "Persistence")
bool IsDoorOpen(FName DoorId) const;
/** 设置指定门的开关状态(写入持久层) */
UFUNCTION(BlueprintCallable, Category = "Persistence")
void SetDoorOpen(FName DoorId, bool bIsOpen);
// === 重置 ===
/** 重置所有持久数据(新游戏时调用) */
UFUNCTION(BlueprintCallable, Category = "Persistence")
void ResetAll();
// === 收集系统状态 ===
// 下方三个成员记录追踪品类的收集进度(收集系统用,技术方案第12节)。
// 与上方的"交互物消耗状态"是两套完全独立的数据。
/** 获取收集状态(恢复用):追踪品类的已收集 ID 集合、当前进度、是否全部完成 */
UFUNCTION(BlueprintCallable, Category = "Persistence|Collection")
void GetCollectionState(TSet<FName>& OutIDs, int32& OutProgress, bool& OutComplete) const;
/** 设置收集状态(收集时写入) */
UFUNCTION(BlueprintCallable, Category = "Persistence|Collection")
void SetCollectionState(const TSet<FName>& InIDs, int32 InProgress, bool bInComplete);
private:
/** 已消耗交互物 ID 集合(一次性交互物防重复触发) */
UPROPERTY()
TSet<FName> ConsumedInteractableIDs;
/** 门开关状态映射(true=打开,false=关闭) */
UPROPERTY()
TMap<FName, bool> DoorStates;
// === 收集系统数据 ===
/** 追踪品类的已收集物品 ID 集合(收集系统用) */
UPROPERTY()
TSet<FName> CollectionCollectedIDs;
/** 当前收集进度(收集系统用) */
UPROPERTY()
int32 CollectionProgress = 0;
/** 是否已全部收集完成(收集系统用) */
UPROPERTY()
bool bCollectionComplete = false;
};
@@ -1,67 +0,0 @@
// ChildHood Project - 拾取通知 UI 基类 实现
#include "Interaction/PickupNotificationWidget.h"
#include "Animation/WidgetAnimation.h" // UWidgetAnimation 完整定义(GetEndTime 需要完整类型)
#include "Components/TextBlock.h"
void UPickupNotificationWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
// 初始隐藏
SetVisibility(ESlateVisibility::Hidden);
}
void UPickupNotificationWidget::SetPickupText(const FString& Text)
{
if (PickupTextBlock)
{
PickupTextBlock->SetText(FText::FromString(Text));
}
}
void UPickupNotificationWidget::Show()
{
if (bIsVisible) return;
bIsVisible = true;
SetVisibility(ESlateVisibility::HitTestInvisible);
if (FadeInAnim)
{
PlayAnimation(FadeInAnim, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f);
}
}
void UPickupNotificationWidget::Hide()
{
if (!bIsVisible) return;
// 淡出时长从 FadeOutAnim 实际时长读取,消除与动画时长耦合的硬编码
const float FadeOutDuration = (FadeOutAnim ? FadeOutAnim->GetEndTime() : 0.2f);
if (FadeOutAnim)
{
PlayAnimation(FadeOutAnim, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f);
}
if (UWorld* World = GetWorld())
{
// 捕获弱引用避免悬空 this
TWeakObjectPtr<UPickupNotificationWidget> WeakThis(this);
FTimerHandle TempHandle;
World->GetTimerManager().SetTimer(TempHandle, [WeakThis]()
{
if (UPickupNotificationWidget* Self = WeakThis.Get())
{
Self->bIsVisible = false;
Self->SetVisibility(ESlateVisibility::Hidden);
}
}, FadeOutDuration, false);
}
else
{
bIsVisible = false;
SetVisibility(ESlateVisibility::Hidden);
}
}
@@ -1,69 +0,0 @@
// ChildHood Project - 拾取通知 UI 基类
// 对应技术方案: docs/technical/交互系统-技术方案.md 第7节(WBP_PickupNotification)
// 职责:拾取/获得物品时的通知弹窗("找到了 XXX"),带淡入淡出
// 注意:执行案 §跨系统交互规定拾取通知归物品系统控制,本基类为交互系统 ShowPickupNotification
// Action 提供表现能力,v0.1 暂由 EffectManager 日志占位或本 Widget 简化显示,物品系统
// 接通后(P-013 后续)再正式启用。
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "PickupNotificationWidget.generated.h"
class UTextBlock;
/**
* UPickupNotificationWidget
*
* 拾取通知 UI 的 C++ 基类。蓝图子类(WBP_PickupNotification)继承此类,
* 在 UMG 编辑器中布局可视组件,并确保组件名与 BindWidget 匹配。
*
* 绑定要求:
* - TextBlock 名为 "PickupTextBlock" — 显示拾取通知文字(如 "找到了 旧橡皮!")
* - WidgetAnimation 名为 "FadeInAnim" — 淡入动画(0.2s)
* - WidgetAnimation 名为 "FadeOutAnim" — 淡出动画(0.2s)
*
* 布局要求:
* - Anchors: 右下角或屏幕中下,与 NarrativeText 错位
* - Border: 半透明背景,圆角
* - TextBlock: 白色文字,居中
*/
UCLASS(Abstract, Blueprintable)
class UPickupNotificationWidget : public UUserWidget
{
GENERATED_BODY()
public:
/** 设置通知文本内容 */
UFUNCTION(BlueprintCallable, Category = "Interaction|Pickup")
void SetPickupText(const FString& Text);
/** 显示通知(播放淡入动画) */
UFUNCTION(BlueprintCallable, Category = "Interaction|Pickup")
void Show();
/** 隐藏通知(播放淡出动画) */
UFUNCTION(BlueprintCallable, Category = "Interaction|Pickup")
void Hide();
protected:
/** 通知文字 TextBlock,蓝图子类必须有一个同名 TextBlock */
UPROPERTY(meta = (BindWidget))
UTextBlock* PickupTextBlock;
/** 淡入动画(0.2s),蓝图子类必须有同名 WidgetAnimation */
UPROPERTY(meta = (BindWidgetAnim), Transient)
UWidgetAnimation* FadeInAnim;
/** 淡出动画(0.2s),蓝图子类必须有同名 WidgetAnimation */
UPROPERTY(meta = (BindWidgetAnim), Transient)
UWidgetAnimation* FadeOutAnim;
/** 是否正在显示中 */
bool bIsVisible = false;
virtual void NativeOnInitialized() override;
};
-156
View File
@@ -1,156 +0,0 @@
// ChildHood Project - 系统驱动器 实现(v1.1 Effect/Action 架构)
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:全局唯一的 World 级驱动器,持有所有 Manager,负责初始化、Tick、跨 Manager 协调
#include "SystemDriver.h"
#include "ChildHood.h"
#include "Engine/World.h"
#include "EngineUtils.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/Character.h"
#include "ChildHoodCharacter.h"
#include "Interaction/PersistenceSubsystem.h"
// ============================================================================
// 构造函数
// ============================================================================
ASystemDriver::ASystemDriver()
{
// AInfo 默认不需要 Tick,我们在构造函数中开启
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = true;
}
// ============================================================================
// 生命周期
// ============================================================================
void ASystemDriver::BeginPlay()
{
Super::BeginPlay();
// --- 初始化 InteractionManager ---
if (UWorld* World = GetWorld())
{
if (APlayerController* PC = World->GetFirstPlayerController())
{
if (ACharacter* Char = PC->GetCharacter())
{
InteractionManager.Initialize(Char);
UE_LOG(LogChildHood, Log, TEXT("SystemDriver: InteractionManager 初始化完成"));
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("SystemDriver: PlayerController 无 Character,InteractionManager 未初始化"));
}
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("SystemDriver: World 中无 PlayerController"));
}
}
// --- 初始化 EffectManager ---
{
FOnEffectComplete Callback;
Callback.BindLambda([this]() { this->OnEffectComplete(); });
EffectManager.SetOnEffectComplete(MoveTemp(Callback));
// P-016:注入表现层 Widget Class(将编辑器配置的 WBP 蓝图类传给 EffectManager)
EffectManager.SetWidgetClasses(NarrativeWidgetClass, PickupWidgetClass);
UE_LOG(LogChildHood, Log, TEXT("SystemDriver: EffectManager 初始化完成"));
}
// --- 初始化 CollectionManager(收集系统,技术方案第13节回调连线)---
{
// 1. 本地状态重置
CollectionManager.Initialize();
// 2. 从持久层恢复进度 + 缓存 Subsystem 弱引用
if (UGameInstance* GI = GetGameInstance())
{
if (UPersistenceSubsystem* Persistence = GI->GetSubsystem<UPersistenceSubsystem>())
{
CollectionManager.RestoreFromPersistence(Persistence);
}
}
// 3. 回调连线:EffectManager AddToBackpack Action → CollectionManager.OnItemObtained
// 交互系统与收集系统不直接耦合,通过 Driver 的 lambda 连线(v1.0 解耦核心)
FOnItemAdded ItemAddedCallback;
ItemAddedCallback.BindLambda([this](FName ItemId, const FString& DisplayName, const FName& Category)
{
CollectionManager.OnItemObtained(ItemId, DisplayName, Category);
});
EffectManager.SetOnItemAddedCallback(MoveTemp(ItemAddedCallback));
UE_LOG(LogChildHood, Log, TEXT("SystemDriver: CollectionManager 初始化完成 + AddToBackpack 回调连线"));
}
}
void ASystemDriver::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// --- Tick EffectManager ---
// InteractionManager 无 Tick(纯事件驱动)
EffectManager.Update(DeltaTime);
}
// ============================================================================
// 全局访问
// ============================================================================
ASystemDriver* ASystemDriver::Get(const UObject* WorldContextObject)
{
if (!WorldContextObject)
{
return nullptr;
}
UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull);
if (!World)
{
return nullptr;
}
// 使用 TActorIterator 查找场景中的 ASystemDriver
// 全局唯一,取第一个即可
for (TActorIterator<ASystemDriver> It(World); It; ++It)
{
return *It;
}
// 场景中不存在时返回 nullptr
// 应在关卡中放置一个 ASystemDriver,或在 GameMode::BeginPlay 中 Spawn
UE_LOG(LogChildHood, Verbose, TEXT("SystemDriver::Get: 场景中未找到 ASystemDriver"));
return nullptr;
}
// ============================================================================
// Effect 完成回调
// ============================================================================
void ASystemDriver::OnEffectComplete()
{
// Effect 完成后通知 InteractionManager 恢复 Free 状态
InteractionManager.OnInteractionFinished();
// 通知 Character 复位交互状态(解除移动屏蔽)。
// FInteractionManager 作为纯逻辑层不操作 Character 表现状态,
// 由 ASystemDriver 作为 World 级协调者统一驱动。
if (UWorld* World = GetWorld())
{
if (APlayerController* PC = World->GetFirstPlayerController())
{
if (AChildHoodCharacter* Char = Cast<AChildHoodCharacter>(PC->GetCharacter()))
{
Char->SetInteracting(false);
}
}
}
}
-96
View File
@@ -1,96 +0,0 @@
// ChildHood Project - 系统驱动器(v1.1 Effect/Action 架构)
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:全局唯一的 World 级驱动器,持有所有 Manager(FInteractionManager, FEffectManager),
// 负责初始化、Tick 驱动、跨 Manager 协调。
// 架构:AInfo 子类(不渲染、不占场景空间),每关只有一个实例。
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Info.h"
#include "Interaction/InteractionManager.h"
#include "Interaction/EffectManager.h"
#include "Collection/CollectionManager.h"
#include "SystemDriver.generated.h"
class UNarrativeTextWidget;
class UPickupNotificationWidget;
/**
* ASystemDriver
*
* 全局唯一的 World 级系统驱动器。持有 FInteractionManager 和 FEffectManager,
* 负责它们的初始化、Tick 驱动和跨 Manager 协调。
*
* 访问方式:ASystemDriver::Get(WorldContextObject) 静态方法。
*
* 数据流:
* InteractableComponent 碰撞回调 → Get() → GetInteractionManager().Register/Unregister
* Character 输入回调 → Get() → GetInteractionManager().BeginInteract/Cancel/Switch
* EffectManager 完成 → OnEffectComplete → GetInteractionManager().OnInteractionFinished
*/
UCLASS()
class ASystemDriver : public AInfo
{
GENERATED_BODY()
public:
ASystemDriver();
// === 生命周期 ===
virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
// === 全局访问 ===
/**
* 获取当前 World 中的 ASystemDriver 实例。
* WorldContextObject 用于获取 World 上下文(通常传 this 或 GetWorld())。
*/
UFUNCTION(BlueprintPure, Category = "System", meta = (WorldContext = "WorldContextObject"))
static ASystemDriver* Get(const UObject* WorldContextObject);
// === Manager 访问 ===
/** 获取交互管理器(值引用) */
FInteractionManager& GetInteractionManager() { return InteractionManager; }
const FInteractionManager& GetInteractionManager() const { return InteractionManager; }
/** 获取效果管理器(值引用) */
FEffectManager& GetEffectManager() { return EffectManager; }
const FEffectManager& GetEffectManager() const { return EffectManager; }
/** 获取收集管理器(值引用)— 收集系统逻辑核心 */
FCollectionManager& GetCollectionManager() { return CollectionManager; }
const FCollectionManager& GetCollectionManager() const { return CollectionManager; }
private:
/** 交互管理器 */
FInteractionManager InteractionManager;
/** 效果管理器 */
FEffectManager EffectManager;
/** 收集管理器(收集系统逻辑核心) */
FCollectionManager CollectionManager;
// === 表现层 Widget 配置点(P-016)===
// FEffectManager 是普通 C++ 类不能持有 UPROPERTY,编辑器配置点放此处,
// BeginPlay 时通过 EffectManager.SetWidgetClasses() 注入。
/** 叙事文本 Widget 蓝图类(编辑器指定 WBP_NarrativeText)。留空则回退日志占位 */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
TSubclassOf<UNarrativeTextWidget> NarrativeWidgetClass;
/** 拾取通知 Widget 蓝图类(编辑器指定 WBP_PickupNotification)。留空则回退日志占位 */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
TSubclassOf<UPickupNotificationWidget> PickupWidgetClass;
// === Effect 完成回调连线 ===
/** EffectManager 的 OnEffectComplete 回调目标 */
void OnEffectComplete();
};
-211
View File
@@ -1,211 +0,0 @@
# ai-art-research — AI 美术参考图生产工具链
> 为 ChildHood 项目(千禧年城郊童年模拟 · UE5.7 · 3D Lowpoly)稳定产出**统一风格美术参考图**的全套工具、素材与方法论。
> 创建时间:2026-06-06 · 风格方向:偏《深夏时光!二十世纪的暑假》现代 3D 卡通渲染
---
## 一、它是什么
这套工具解决一个具体问题:**让一个非美术专业、但"知道自己想要什么感觉"的开发者,能用 AI 稳定地生成符合项目统一风格的美术参考图**(注意:是概念参考图,不是最终游戏资产)。
核心难点是**双参考融合**——手里有两类素材:
| 素材类型 | 作用 | 来源 |
|---------|------|------|
| **风格参考图**("怎么画") | 锁定美术风格 | 《深夏时光》游戏截图 |
| **内容参考图**("画什么") | 提供场景内容 | 千禧年中国城郊真实照片 |
目标输出 = **用风格参考的画法,呈现内容参考的场景**。
工具链由三层组成:
1. **方法论层** — [`AI风格化生图工作流方案.md`](./AI风格化生图工作流方案.md):四步走流程 + 预置风格 Prompt 模板
2. **工具层** — [`tools/image_gen.py`](./tools/image_gen.py):封装 API 调用的 Python CLI
3. **调研选型层** — [`调研文档/`](./调研文档/):平台与模型横评
---
## 二、目录结构
```
ai-art-research/
├── README.md # 本文件
├── .env.example # API易 配置模板(复制为 .env 填写)
├── .env # 实际配置(含密钥,已被 .gitignore 排除)
├── AI风格化生图工作流方案.md # ⭐ 核心方法论文档(主入口)
│
├── tools/
│ └── image_gen.py # ⭐ Python 生图 CLI
│
├── 参考图/ # 输入素材库(共 230 张)
│ ├── 参考图素材链接清单.md # 各素材下载链接
│ ├── 待补充素材.md # 占位(当前为空)
│ ├── 游戏截图/ # 44 张(《深夏时光》《我的暑假》《放学后少年》)
│ ├── 真实场景/ # 125 张(千禧年中国城郊实景)
│ └── 美术风格/ # 61 张(Lowpoly/怀旧风格参考项目)
│
├── 生成结果/ # 输出库(每张图配套 .meta.json)
│ ├── 测试/ # --test 连通性测试产出
│ ├── 风格校准/ # --calibrate 5 物体校准产出(待补)
│ ├── 双参考融合/ # --fuse 产出(待补)
│ ├── 国内模型/ # 横评产出(待补)
│ └── 国外模型/ # 横评产出(待补)
│
└── 调研文档/
├── 01-聚合站与中转站调研.md # 平台选型
└── 02-模型测试计划.md # 模型横评计划与评分表
```
> ⚠️ 本目录被项目根 `.ignore` 屏蔽,**不进入 ripgrep/Glob 搜索**(这是有意为之,避免参考图/产出图污染代码检索)。查找本目录内容需直接读文件或用 `find`。
---
## 三、核心方法论速览
详见 [`AI风格化生图工作流方案.md`](./AI风格化生图工作流方案.md),要点如下:
### 1. 风格六维度框架
用"做菜配方"的方式拆解任意一张图的风格,便于非美术人员描述:
| 维度 | 观察点 |
|------|--------|
| 几何/形体 | 简化程度、边缘锐利还是圆润 |
| 色彩 | 主色、冷暖、饱和度 |
| 光照 | 光源方向、阴影软硬、整体亮度 |
| 纹理 | 表面光滑/粗糙、有无笔触 |
| 构图 | 俯视/平视、近景/远景 |
| 氛围 | 三个词描述感受 |
### 2. 风格种子(已硬编码进脚本,自动注入)
英文版(作为生图 prompt 前缀):
> *"3D low-poly game art, flat cel-shaded with solid color blocks, bright warm-cool balanced saturated palette, soft diffused lighting with subtle shadows, simplified geometric forms with clean edges, low-angle third-person perspective, nostalgic warm summer atmosphere"*
### 3. 风格方向(已确认)
- ✅ 中等几何简化、保留圆滑曲线;鲜艳明亮色彩;柔和自然阴影;干净纯色平面着色(cel-shaded)
- ❌ 不追求 PS1 的像素化贴图、vertex jitter 等复古缺陷
### 4. 三工具分工策略
| 角色 | 工具 | 用途 |
|------|------|------|
| 主力生图 | **GPT-Image-2** | 双参考融合,精确控制风格 + 内容 |
| 风格锁定 | **Nano Banana 2 (Gemini)** | 上传多张(最多 14 张)参考图做风格锚定 |
| 中文探索 | **Seedream 5 (即梦)** | 自然中文描述探索方向 |
---
## 四、工具使用:`image_gen.py`
通过 **API易 平台**(OpenAI 兼容格式)调用生图模型。每次产出同时保存图片和 `.meta.json`(记录模型、prompt、时间戳等,便于复现)。
### 环境准备
```bash
cd ai-art-research
cp .env.example .env # 填入 APIYI_API_KEY
pip install openai python-dotenv
```
`.env` 关键字段:
| 字段 | 说明 | 默认 |
|------|------|------|
| `APIYI_API_KEY` | API易 令牌(必填,https://api.apiyi.com/token 创建) | — |
| `APIYI_BASE_URL` | API 地址 | `https://api.apiyi.com/v1` |
| `APIYI_MODEL` | 默认模型 | `gpt-image-2` |
### 四个命令
```bash
# 1) 测试连通性(生成一张简单测试图到 生成结果/测试/)
python tools/image_gen.py --test
# 2) 文生图(自动加风格前缀;--no-style 关闭)
python tools/image_gen.py -g "千禧年中国城郊的小卖部门口"
python tools/image_gen.py -g "a red apple" --no-style
# 3) 风格校准 —— 用同一风格种子生成 5 个固定物体(乌鸦/茶杯/老人/山湖/苹果),验证风格一致性
python tools/image_gen.py --calibrate
# 4) 双参考融合 —— 风格参考 + 内容参考
python tools/image_gen.py --fuse --style 深夏时光截图.png --content 城郊照片.jpg
python tools/image_gen.py --fuse -s style.png -c photo.jpg -p "自定义 prompt"
```
常用可选参数:`-m/--model` 换模型、`--size`(默认 1024x1024)、`--quality`(low/medium/high,迭代用 medium、最终用 high)、`--subdir` 自定义输出子目录。
### 双参考融合的实现细节
脚本对 API 调用做了两级降级:
1. 优先 `images.edit`(多图编辑模式,两张图都上传)
2. 失败则回退 `images.generate + extra_body`
3. 全部失败会提示改用网页端手动操作(API易 可能不支持多图 `images.edit` 端点)
---
## 五、调研与选型结论
### 平台选型(详见 [01-聚合站与中转站调研.md](./调研文档/01-聚合站与中转站调研.md))
| 类型 | 推荐 | 理由 |
|------|------|------|
| 网页端一站式 | **Monica** | 一个平台覆盖 Claude 分析 + GPT-Image-2 + Nano Banana 2 |
| API 中转(脚本用) | **API易** ⭐ | 性价比最高,GPT-Image-2 ~0.04-0.05 元/张,token 永不过期 |
> ⚠️ 中转站行业不稳定,**不要大额充值**;注意"注水"问题(低端模型冒充高端)。
### 模型横评(详见 [02-模型测试计划.md](./调研文档/02-模型测试计划.md))
计划对 8+ 模型用**统一 prompt** 横评:国内(可灵 Kling / 即梦 Seedream / 通义万相 / 混元 / CogView)+ 国外(GPT-Image-2 / Nano Banana 2 / Claude)。
- 四套测试:A 场景概念图、B 角色设计、C 风格一致性(同 prompt 连生 3 次)、D 风格分析
- 五维评分:画质 / 风格匹配 / Prompt 遵循 / 中国元素 / 一致性
- 📌 **评分表尚未填写,横评进行中**
---
## 六、当前状态与待办
### ✅ 已就绪
- 方法论文档与风格 Prompt 模板 v0.1
- `image_gen.py` 四命令 CLI(`--test` 已跑通,`生成结果/测试/` 有 2026-06-10 产出)
- 素材库充实(游戏截图 44 / 真实场景 125 / 美术风格 61)
- 根目录有早期网页端手工测试产出若干(gpt-image-2 / gemini-3 系)
### 🔲 待补
- [ ] 完成模型横评并填写评分表 → 产出选型结论
- [ ] 跑通 `--calibrate` 与 `--fuse`,填充对应产出目录
- [ ] 工作流方案规划但尚未产出的文档:`风格词汇表.md`、`风格Prompt模板.md`、`选型结论.md`
- [ ] 补充 `参考图/待补充素材.md`
---
## 七、与主项目的关系
- 本目录是 **研究/工具性质**,独立于 UE5 工程;产出物(风格结论、参考图)按需回流到主项目。
- 工作流方案明确要求:**同步更新 [`docs/art/美术风格指南.md`](../docs/art/美术风格指南.md)**,把"类似 PS1 游戏的渲染风格"改为"类似《深夏时光》的现代 3D 卡通渲染风格"。
- 用户记忆中已记录本工具的风格方向(见 `~/.claude/.../memory/ai-art-style-direction.md`)。
- Git:目录纳入版本管理,仅 `.env`(密钥)被 `.gitignore` 排除。
---
## 八、快速开始(5 分钟)
```bash
# 1. 配置密钥
cd ai-art-research && cp .env.example .env
# 编辑 .env,填入 APIYI_API_KEY
# 2. 安装依赖
pip install openai python-dotenv
# 3. 验证连通
python tools/image_gen.py --test
# 4. 文生图试一张(自动带风格前缀)
python tools/image_gen.py -g "千禧年中国城郊的小卖部门口"
# 5. 双参考融合(核心玩法)
python tools/image_gen.py --fuse \
--style "参考图/游戏截图/深夏时光01.png" \
--content "参考图/真实场景/城郊小卖部.jpg"
```
不满意就用自然语言迭代 prompt:"几何再简化一点"、"色彩再温暖一些"、"下午4点的光线"。
-252
View File
@@ -1,252 +0,0 @@
# codex 生图工作规范(用 ChatGPT Plus 订阅额度)
> 让日常 AI 生图走 **Codex CLI 内置 `image_gen` 工具**,消耗 ChatGPT Plus 会员的订阅额度,**不按张计费**。
> 创建时间:2026-06-20 · 配套现有 [`tools/image_gen.py`](./tools/image_gen.py)(apiyi 通道,降级为兜底)
---
## 0. TL;DR
- **日常生图默认走 codex 通道**(订阅额度,不花钱),**apiyi 仅作兜底**。
- codex 内置 `image_gen` 用的就是 **`gpt-image-2`**——和 apiyi 同一个模型,质量一致。
- **执行方式(重要)**:`codex exec` 由**用户在自己的终端(PowerShell)运行**。Claude Code 的安全分类器会硬拦 `codex exec --dangerously-bypass-approvals-and-sandbox`("Create Unsafe Agents",**不可配置放行**),所以 **Claude 不能自动跑**这条命令。分工:**用户终端跑 codex 生图 → Claude 读结果并完成捞图 / 命名 / 写 `.meta.json` 收尾**。
- **完整复用** 现有 `prompts/*.txt`、`STYLE_SEED_EN` 风格种子、`生成结果/` 命名规则、`.meta.json`。
- 命令模板记一句话:`codex exec --dangerously-bypass-approvals-and-sandbox -C <dir> -o <lastmsg.txt> "<prompt>"`(**用户终端执行**)
---
## 1. 背景:为什么多了这条通道
| | codex 通道(新增·默认) | apiyi 通道(原有·兜底) |
|---|---|---|
| 模型 | `gpt-image-2`(Codex 内置 image_gen 工具) | `gpt-image-2`(apiyi 中转) |
| 计费 | **ChatGPT Plus 订阅额度**,不按张计费 | 按张计费,约 0.04–0.05 元/张 |
| 需要 API Key | ❌ 不需要 `OPENAI_API_KEY` | ✅ 需要 `APIYI_API_KEY` |
| 文生图 | ✅ | ✅ |
| 多参考融合 | ✅ 最多 5 张 | ✅(`images.edit`,apiyi 支持度不稳) |
| 编辑 | ✅ | ❌(现有工具未封装) |
| 透明背景 | ⚠️ gpt-image-2 不支持,需 chroma-key 变通 | ⚠️ 同 |
| 限额 | ChatGPT Plus 约 **40–50 张 / 3 小时**滚动窗口 | 无(按张付费) |
| 附加成本 | 每张额外消耗 ~30k Codex agent token | 无 |
| **执行者** | **用户在终端跑** codex exec(Claude 收尾) | Claude 直接跑 `python tools/image_gen.py` |
| 自动化程度 | 半自动(生图执行步手动) | 全自动 |
**为什么 codex 通道是"半自动"**:Claude Code 的 auto mode 分类器把 `codex exec --dangerously-bypass-approvals-and-sandbox` 判为"创建不安全自主 agent",是硬安全规则、任何 settings 配置都放行不了(`allow:["Bash"]`、`bypassPermissions` 都无效)。所以生图这一步必须由人在终端按一下回车;Claude 承担其余所有工作(组装 prompt、捞图、命名、写 meta)。
**何时用哪个**:
- 默认 → **codex**(省钱,生图步在终端手动跑)。
- 撞 429 限额 / 需真·透明背景 / 要 Claude 全自动批量 → **apiyi 兜底**。
---
## 2. 前置自检(本机已实测通过 ✅)
```bash
# codex 可执行绝对路径(codex 不在默认 PATH,见下一节)
CODEX="/c/Users/starm/.codex/packages/standalone/current/bin/codex.exe"
"$CODEX" --version # → codex-cli 0.141.0 ✅
"$CODEX" login status # → "Logged in using ChatGPT" ✅(走订阅,非 API 计费)
"$CODEX" features list | grep image_generation # → image_generation stable true ✅
grep auth_mode ~/.codex/auth.json # → "auth_mode": "chatgpt", ✅
```
**解读**:`auth_mode=chatgpt` = 走 ChatGPT OAuth 订阅额度;若显示 `api_key` 则是按张计费,需 `codex login` 重新用 ChatGPT 登录。
---
## 3. codex 怎么调(重要:PATH 问题)
本机 codex 是 standalone 安装,**不在默认 PATH**。二进制在:
```
~/.codex/packages/standalone/current/bin/codex.exe # current 是指向当前版本的软链接
# 即 C:\Users\starm\.codex\packages\standalone\current\bin\codex.exe
```
**用户在 PowerShell 里调用**(推荐先赋变量,命令更短):
```powershell
$CODEX = "$env:USERPROFILE\.codex\packages\standalone\current\bin\codex.exe"
$RESEARCH = "E:\MiceCola\work\ChildHood\ai-art-research"
& $CODEX --version # 验证能跑
```
> 若你已把 codex 加进 PATH,可直接用 `codex` 代替 `& $CODEX`。
> Claude(git bash)做收尾时用 `/c/Users/starm/.codex/packages/standalone/current/bin/codex.exe` 这个路径形式。
---
## 4. Codex 通道 SOP(协作模式)
**协作流程**:
```
① Claude 组装 prompt(注入风格种子 / 读 prompts/ 文件)
② 用户在 PowerShell 跑 codex exec(生图,写到 -o 文件 + 默认目录)
③ Claude 读 -o 文件取路径 → copy + 按命名规则 rename → 写 .meta.json
```
### 4.1 用户终端执行的命令模板(PowerShell)
```powershell
$CODEX = "$env:USERPROFILE\.codex\packages\standalone\current\bin\codex.exe"
$RESEARCH = "E:\MiceCola\work\ChildHood\ai-art-research"
& $CODEX exec `
--enable image_generation `
--dangerously-bypass-approvals-and-sandbox `
--skip-git-repo-check `
-C $RESEARCH `
-o "$RESEARCH\.codex_lastmsg.txt" `
"<见 4.2 / 4.3 / 4.4 各场景的 prompt>"
```
**四个不可省的 flag**:
1. `--enable image_generation` —— **关键**:`codex exec` 默认不启用图像生成 feature,会话里不会暴露内置 `image_gen` 工具(agent 会报"当前会话没有 image_gen 工具")。`codex features list` 的 `stable=true` 只代表"受支持",不代表"已启用";必须用此 flag,或在 `~/.codex/config.toml` 的 `[features]` 写 `image_generation = true` 才真正启用。
2. `--dangerously-bypass-approvals-and-sandbox` —— 不加会被默认沙箱断网(`Could not resolve host: api.openai.com`)。
3. `-C/--cd` 指向 `ai-art-research` —— 让 Codex 能直接读 `prompts/`、`参考图/`,也便于落盘。
4. `--skip-git-repo-check` —— 该目录不是 git 仓库根,跳过检查。
**捞图利器 `-o`**:把 Codex 最后一条消息(含生成图路径)写到 `.codex_lastmsg.txt`,Claude 读它取路径——比从 stdout 自然语言里正则捞可靠得多。(也可用 `--json` 输出 JSONL 事件流解析。)
**prompt 里必带的一句**(防止 Codex 跑偏去写脚本、走 API 变回计费):
> 「用 Codex 内置的 image_gen 工具直接生成,不要写任何 Python/Node 脚本,不要使用 OPENAI_API_KEY 或外部 Images API。生成完成后告诉我图片的完整文件路径。」
### 4.2 文生图(对应 `image_gen.py -g`)
Codex 的 prompt 由 Claude 组装:**`STYLE_SEED_EN` + 用户内容**(自动加风格前缀,与现有脚本一致)。把下面整段作为 4.1 模板里的 `<prompt>`:
```
用 Codex 内置的 image_gen 工具(gpt-image-2)直接生成 1 张图。
不要写任何 Python/Node 脚本,不要使用 OPENAI_API_KEY 或外部 Images API。
Prompt: 3D low-poly game art, flat cel-shaded with solid color blocks, bright warm-cool balanced saturated palette, soft diffused lighting with subtle shadows, simplified geometric forms with clean edges, low-angle third-person perspective, nostalgic warm summer atmosphere, <用户内容或 prompts/xxx.txt 的内容>
尺寸: 1024x1024 质量: medium
生成完成后,告诉我图片的完整文件路径。
```
> 风格种子原文见 [`tools/image_gen.py`](./tools/image_gen.py) 的 `STYLE_SEED_EN`(不要手抄,Claude 直接引用)。
> `prompts/*.txt`(如 `居民楼-好楼房-标准单元-楼型1.txt`)由 Claude 读取后整段作为「<用户内容>」注入,再由用户复制完整命令到终端跑。
### 4.3 双参考融合(对应 `image_gen.py --fuse`)
Codex 会用内置 `view_image` 加载本地图再融合。多图按 `Image 1 / Image 2 /…` 引用:
```
用 Codex 内置的 image_gen 工具(gpt-image-2)直接生成 1 张图,不要写脚本、不要用 API key。
参考图(已在工作目录下,请用 view_image 加载):
- Image 1(风格参考): ./参考图/游戏截图/深夏时光01.png —— 只取它的画法与色调,不要它的日本小镇内容
- Image 2(内容参考): ./参考图/真实场景/城郊小卖部.jpg —— 取它的场景布局与建筑结构
要求:用 Image 1 的美术风格呈现 Image 2 的场景;保持千禧年中国城郊风貌,温暖怀旧夏日午后。
尺寸: 1024x1024 质量: medium
生成完成后,告诉我图片的完整文件路径。
```
> 中文融合模板可复用 [`tools/image_gen.py`](./tools/image_gen.py) 的 `DEFAULT_FUSION_PROMPT`。
> 支持**最多 5 张**参考图(风格 1 + 内容 N)。迭代编辑时**每次复述不变量**("只改 X,保持 Y 不变")以减少漂移。
> 实测提示:Codex agent 对"先 view_image 再融合"一般正确执行;若融合没生效,把参考图 copy 进 `--cd` 目录用相对路径重试。
### 4.4 编辑(现有工具未覆盖,codex 通道的能力增强)
```
用 Codex 内置的 image_gen 工具编辑已有图片,不要写脚本、不要用 API key。
编辑目标(用 view_image 加载): ./生成结果/双参考融合/好楼房/xxx.png
改动:只把背景天空换成傍晚暖光,保持建筑本身、比例、画风完全不变。
生成完成后,告诉我新图片的完整文件路径。
```
### 4.5 捞图 + 命名落盘 + 写 meta(Claude 收尾,严格复用现有规则)
**用户在终端跑完 4.1 命令后,告诉 Claude"跑完了",由 Claude 做以下收尾**(用户也可自己按规则手动落盘):
1. **取路径**:读 `-o` 指定的 `ai-art-research/.codex_lastmsg.txt`,提取生成图完整路径;取不到就 glob `~/.codex/generated_images/` 下最新 `ig_*.png`。
2. **落盘**:copy 成现有命名格式(与 `image_gen.py` 的 `save_result()` 完全一致):
- 路径:`ai-art-research/生成结果/<子目录>/<时间戳>_gpt-image-2_<模式>.png`
- 时间戳:`datetime.now().strftime("%y%m%d-%H%M%S")`,如 `260620-213000`
- 模式 slug:`gen` / `calib` / `fusion` / `test`(沿用 `_MODE_SLUG`)
- 子目录:`文生图` / `风格校准` / `双参考融合/<分类>` / `测试`
3. **写 `.meta.json`**(同名),在原 schema 上**新增 `backend` 字段**标记来源:
```json
{
"model": "gpt-image-2",
"backend": "codex",
"prompt": "<完整 prompt,含风格种子>",
"mode": "text_to_image",
"style_prefix_used": true,
"size": "1024x1024",
"quality": "medium",
"timestamp": "<ISO>"
}
```
融合场景的 meta 另含 `"style_reference"` / `"content_references"`(参考图路径数组),与现有 `--fuse` 产物一致。
> `.codex_lastmsg.txt` 是临时文件,读完即可删(`ai-art-research/` 整目录已被项目 `.ignore` 屏蔽,不进 git)。
---
## 5. apiyi 兜底 SOP
触发条件(任一满足即切 apiyi,且这条通道 Claude 可全自动跑):
1. Codex 报 **429 / 限额**提示(Plus 约 40–50 张/3h 窗口)。
2. 需要**真·透明背景**(gpt-image-2 不支持)。
3. 需要 **Claude 全自动批量**(不想手动跑终端)与 100% 确定性输出。
切法:直接用现有脚本(`backend` 标 `apiyi`):
```bash
cd /e/MiceCola/work/ChildHood/ai-art-research
python tools/image_gen.py -g "千禧年中国城郊的小卖部门口" # 文生图
python tools/image_gen.py --fuse -s 参考图/游戏截图/深夏时光01.png \
-c 参考图/真实场景/城郊小卖部.jpg # 双参考融合
python tools/image_gen.py --fuse -s style.png -c photo.jpg -pf prompts/居民楼-好楼房-标准单元-楼型1.txt
```
> 现有脚本未写 `backend` 字段;用 apiyi 通道时,Claude 在产物 `.meta.json` 里补 `"backend": "apiyi"` 以区分来源。详见 [`README.md`](./README.md)。
---
## 6. gpt-image-2 约束清单(codex 与 apiyi 通用)
- **尺寸**:边长必须是 **16 的倍数**;最大边 ≤ **3840px**;长短边比 ≤ **3:1**;总像素 655,360 ~ 8,294,400。
- **常用合规尺寸**:`1024x1024`(默认·最快)/ `1536x1024`(横)/ `1024x1536`(竖)/ `2048x2048`(2K)/ `2048x1152` / `3840x2160`(4K)。
- **透明背景**:gpt-image-2 **不支持** `background=transparent`。
- 建筑/立面图用不到,无影响。
- 将来道具抠图若需透明:① codex 通道走 chroma-key 绿幕 + 本地 `$CODEX_HOME/skills/.system/imagegen/scripts/remove_chroma_key.py` 去背景;② 或 fallback 到 apiyi / `gpt-image-1.5`。
- **限额**:ChatGPT Plus 约 40–50 张/3 小时(动态,OpenAI 不公布固定值);批量出建筑变体时分批,撞限额切 apiyi。
---
## 7. 常见故障
| 现象 | 原因 | 处理 |
|---|---|---|
| `Could not resolve host: api.openai.com` | 没加 `--dangerously-bypass-approvals-and-sandbox`,默认沙箱断网 | 加上该 flag |
| Codex 自己写起了 Python/Node 脚本 | prompt 没明示"用内置工具、不要写脚本" | 在 prompt 开头加那句强制约束 |
| 生成了图但找不到文件 | Codex 存到默认 `~/.codex/generated_images/<session>/ig_*.png` | 读 `-o` 的 lastmsg 取路径,或 glob 该目录最新文件 |
| 429 / 限额 | 撞 Plus 3h 窗口 | 切 apiyi 兜底 |
| `auth_mode: api_key` | codex 当前走 API 计费 | `codex login` 重新用 ChatGPT OAuth 登录 |
| 融合没用到参考图 | Codex 没 view_image 加载 | 把参考图 copy 进 `--cd` 目录,用相对路径 + 明确 `Image 1/2` 引用重试 |
| agent 报"当前会话没有 image_gen 工具" | `codex exec` 默认未启用 image_generation feature | 命令加 `--enable image_generation`(或在 `~/.codex/config.toml` 的 `[features]` 写 `image_generation = true`) |
| Claude 说"我不能跑 codex" | auto mode 分类器硬拦(设计如此) | 这是预期行为;由用户在终端跑该命令,Claude 做收尾 |
---
## 8. 复用资产清单(不要另起炉灶)
| 资产 | 位置 | 怎么用 |
|---|---|---|
| 风格种子 | `tools/image_gen.py` → `STYLE_SEED_EN` | 文生图 prompt 前缀,Claude 引用注入 |
| Prompt 模板(14 个) | `prompts/*.txt` | Claude 读取整段作为生图内容/融合 prompt |
| 融合中文模板 | `tools/image_gen.py` → `DEFAULT_FUSION_PROMPT` | 双参考融合场景的 prompt 骨架 |
| 命名规则 | `tools/image_gen.py` → `save_result()` / `_MODE_SLUG` | `<时间戳>_gpt-image-2_<模式>.png` + 同名 `.meta.json` |
| 参考图库(230 张) | `参考图/游戏截图`、`参考图/真实场景`、`参考图/美术风格` | 融合的输入 |
| 输出目录 | `生成结果/{文生图,风格校准,双参考融合/<分类>,测试}` | codex 产物落这里,与 apiyi 产物混排,靠 meta 的 `backend` 区分 |
---
## 9. 与主项目的关系
- 本规范是 `ai-art-research/` 工具链的一部分,独立于 UE5 工程。
- 项目根 [`CLAUDE.md`](../CLAUDE.md) 的「## AI 生图」一节指向本文件:默认走 codex 通道(用户终端执行 + Claude 收尾),apiyi 兜底。
- `ai-art-research/` 被项目 `.ignore` 屏蔽,不进 ripgrep/Glob 检索;操作需用显式路径(本文件所有命令均用绝对/相对显式路径)。
-134
View File
@@ -1,134 +0,0 @@
# 路径 C 组装 · codex 通道指南(Mac 尝试)
> 用 Codex CLI 内置 `image_gen` 跑「路径 C:风格图 + 外立面图 + 窗户字汇表 + 文字布局 → 单元南立面」。
> 三图融合是 codex 通道的强项:最多 5 张参考图、融合稳定、走 ChatGPT Plus 订阅额度(不按张计费)。
> 创建:2026-06-23
---
## 0. 为什么有这篇 + 通道选择
路径 C 组装是**三图融合**(风格 + 外立面 + 窗户字汇表)。
| 通道 | 三图融合 | 说明 |
|------|----------|------|
| **codex**(本文) | ✅ 稳定 | 最多 5 图、融合可靠、免费(Plus 额度)。**但需人在终端跑**(Claude 的安全分类器硬拦 `--dangerously-bypass-approvals-and-sandbox`,不可配置放行) |
| apiyi 兜底 | ⚠️ 不稳 | `images.edit` 多图支持差,失败会降级到"单图 + 文字",**丢掉窗户字汇表 → 路径 C 破功** |
**Windows 实测结论(2026-06-23)**:本机 Windows 上 codex 内部 `image_gen` 工具调用失败,整条 codex 路径跑不通。已改用 apiyi 兜底先出图验证窗户准确度;**codex 三图融合留待 Mac 上验证**(Mac 环境下 codex 调用 image_gen 的成功率更高)。
> 通用 codex 通道说明见 [`codex生图工作规范.md`](./codex生图工作规范.md)。本文只聚焦「路径 C 组装」这一具体场景。
---
## 1. 前置自检(Mac)
```bash
CODEX="$HOME/.codex/packages/standalone/current/bin/codex" # standalone 安装路径
"$CODEX" --version # 验证可执行
"$CODEX" login status # 应 "Logged in using ChatGpt"(走订阅,非 API 计费)
"$CODEX" features list | grep image_generation # image_generation stable true
grep auth_mode ~/.codex/auth.json # "auth_mode": "chatgpt"
```
- `auth_mode=chatgpt` = 走订阅额度;若显示 `api_key` 则按张计费,需 `codex login` 用 ChatGPT 重新登录。
- codex 不在默认 PATH,必须用绝对路径或先加 PATH。
---
## 2. 素材清单(都在 `ai-art-research/` 下)
| 角色 | 文件 |
|------|------|
| 图1 风格参考 | `参考图/游戏截图/深夏时光/深夏时光-下午-街景.png` |
| 图2 外立面参考 | `参考图/真实场景/东井村/QQ20260616-174350.png` |
| 图3 窗户字汇表 | `生成结果/窗户标准件/标准件合集图.png` |
| 完整 codex prompt(自包含) | `prompts/_codex_路径C组装完整prompt.txt` |
> 窗户字汇表由 `tools/_build_parts_sheet.py` 把 3 个正投影标准件(卧室普通窗 / 厨卫窄窗 / 阳台正面)拼成,生成方法见该脚本。
> 组装正文(不带 codex 约束语、不带图引用的纯净版)存档于 `prompts/居民楼-单元南立面-路径C-窗户字汇表-v1.txt`。
---
## 3. 一条命令跑(Mac bash)
```bash
CODEX="$HOME/.codex/packages/standalone/current/bin/codex"
RESEARCH="/绝对路径/ai-art-research" # ← 改成 Mac 上的实际路径
cd "$RESEARCH"
"$CODEX" exec \
--enable image_generation \
--dangerously-bypass-approvals-and-sandbox \
--skip-git-repo-check \
-C "$RESEARCH" \
-o "$RESEARCH/.codex_lastmsg.txt" \
"$(cat prompts/_codex_路径C组装完整prompt.txt)"
```
**四个不可省的 flag**:
1. `--enable image_generation` —— `codex exec` 默认不启用生图工具,会话里没有 `image_gen`。
2. `--dangerously-bypass-approvals-and-sandbox` —— 不加会被默认沙箱断网。
3. `-C "$RESEARCH"` —— 工作目录设为 `ai-art-research`,让 `./参考图/...` 相对路径能找到。
4. `--skip-git-repo-check` —— 该目录非 git 仓库根。
`-o` 把 codex 最后一条消息(含生成图路径)写到 `.codex_lastmsg.txt`,比从 stdout 捞可靠。
---
## 4. 跑完收尾(命名 + meta)
codex 把生成图存到默认目录(`~/.codex/generated_images/<session>/ig_*.png`),路径也写进 `.codex_lastmsg.txt`。按现有规则落盘到:
```
生成结果/路径C组装/<时间戳>_gpt-image-2_fusion.png
```
同名 `.meta.json`:
```json
{
"model": "gpt-image-2",
"backend": "codex",
"mode": "multi_reference_fusion",
"style_reference": "参考图/游戏截图/深夏时光/深夏时光-下午-街景.png",
"content_references": [
"参考图/真实场景/东井村/QQ20260616-174350.png",
"生成结果/窗户标准件/标准件合集图.png"
],
"prompt": "<prompts/_codex_路径C组装完整prompt.txt 全文>",
"size": "1536x1024",
"quality": "medium",
"timestamp": "<ISO>"
}
```
> `.codex_lastmsg.txt` 是临时文件,读完可删(`ai-art-research/` 整目录已被 `.ignore` 屏蔽,不进 git)。
---
## 5. 验证(路径 C vs 路径 B / v4 基线)
路径 C 的核心假设是「窗户从字汇表复用结构」比 v4「文字钉死结构」更准。重点核对:
- **卧室段**:两扇推拉、每扇宽高比 1:2、把手在两端、两个卧室的窗独立不合并。
- **阳台段**:**三对共 6 扇**、每扇 1:2、横向均匀填满阳台宽度、是外凸全高阳台(不是飘窗、不是两扇大窗)。
- **风格统一**:窗户用 cel-shaded 暖色渲染、与墙面融为一体(**没有**灰平涂工程图贴在暖色立面上的割裂感——这是路径 C 最大风险点)。
- 与 `prompts/居民楼-单元南立面-户型图推导-v4.txt` 的产物正面对比窗户准确度。
---
## 6. 已知坑
| 现象 | 处理 |
|------|------|
| 融合没用到图 3(窗户表) | codex agent 偶尔不 view_image。把 `标准件合集图.png` 复制进 `-C` 目录、用相对路径 + 明确 `Image 1/2/3` 引用重试 |
| 429 / 限额 | Plus 约 40–50 张/3h。撞限额切 apiyi(但 apiyi 三图不稳,见第 0 节) |
| codex 自己写起 Python/Node 脚本 | prompt 开头"用内置工具、不要写脚本"约束语已防;若仍跑偏,重申该约束 |
| Windows 下 codex image_gen 调用失败 | 已知(本文第 0 节)。Mac 上重试;或暂用 apiyi 兜底 |
---
## 7. 后续:三立面 → 单元模型
路径 C 验证通过(窗户准确度优于 v4)后,下一步用同一户型推导该单元的**南 / 北 / 山墙**三个立面,喂给建模 AI 生成一个单元的完整 3D 模型,再实例拼接成整楼——呼应 Issue #44 的最终目标。
@@ -1,65 +0,0 @@
用 Codex 内置的 image_gen 工具(gpt-image-2)直接生成 1 张图,不要写任何 Python/Node 脚本,不要使用 OPENAI_API_KEY 或外部 Images API。
参考图(已在工作目录下,请先用 view_image 逐张加载,再融合生成):
- Image 1(风格参考·《深夏时光》截图): ./参考图/游戏截图/深夏时光/深夏时光-下午-街景.png —— 只取画法与色调
- Image 2(外立面参考·中国城郊居民楼真实照片): ./参考图/真实场景/东井村/QQ20260616-174350.png —— 取材质质感与年代感,不要照搬窗户形态/布局/底部半地下室
- Image 3(窗户字汇表·平涂结构示意图): ./生成结果/窗户标准件/标准件合集图.png —— 窗户几何模具(卧室普通窗/阳台正面/楼梯间窗/厨卫窄窗),只取每种窗的结构
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法与色调,不要它的内容。
图2是我的外立面参考(中国城郊居民楼的真实照片)——取它的材质质感、年代感、色调倾向,【不要照搬它具体的窗户形态和楼层布局,尤其不要照搬它底部的半地下室/架空层(见下方禁止项)】。
图3是我的【窗户字汇表】——里面纵向列了多种窗户样式(卧室普通窗 / 厨卫窄窗 / 阳台正面 / 楼梯间窗),每种都是正投影、平涂色块的【结构示意图】。这是窗户的"几何模具"。
任务:用图1的画法、图2的质感,画【一个单元】的【南立面正投影图】(正对南墙、无透视)。每个位置的窗户要从图3字汇表里【选用对应窗型的几何结构】。
【⚠️ 图3字汇表怎么用——成败关键】
- 字汇表只提供窗户的【几何结构】(几扇、宽高比、把手、开法),【绝不照搬它的灰平涂工程图外观】
- 必须用图1的 cel-shaded 画法,把选中的窗户【重新渲染】成有玻璃质感、暖色调、与墙面融为一体的样子——窗户和墙面风格必须统一,【严禁出现灰平涂的工程图窗户贴在暖色立面上的割裂感】
【单元水平布局——从左到右严格7段,必须照此排列】
整面南立面从最左到最右,依次是:
1. 左户卧室1 —— 卧室普通窗
2. 左户卧室2 —— 卧室普通窗
3. 左户阳台 —— 外凸全高阳台(正面6扇)
4. 单元中央 —— 底层【凹进的单元门洞】+ 上方各层【楼梯间】(楼梯间窗)
5. 右户阳台 —— 外凸全高阳台(正面6扇)
6. 右户卧室2 —— 卧室普通窗
7. 右户卧室1 —— 卧室普通窗
即:左右两户严格镜像,每户内部从左到右都是"卧室1→卧室2→阳台"。【两个卧室1在最外两端,两个阳台相邻靠中央,单元门+楼梯间在最正中央】。
【各位置选窗型——严格对照图3字汇表】
- 卧室1、卧室2(位置1、2、6、7)→ 图3【卧室普通窗】(两扇推拉、每扇宽高比1:2、把手在两端;两卧室窗独立分开,禁止合并)
- 阳台(位置3、5)→ 图3【阳台正面】(三对推拉共6扇、每扇宽高比1:2、横向均匀填满阳台宽度;严禁两扇大窗、严禁宽矮形)
- 楼梯间(位置4上方各层)→ 图3【楼梯间窗】(横向小条窗、宽>高约2:1、又小又横,明显区别于卧室的竖长窗)
【单元门洞——位置4,结构必须画对】
- 位于单元正中央的最底层
- 【凹进门洞】:门洞向墙内凹进(入口内缩于墙面,不是平贴墙面的门)
- 门洞高度约一层楼的3/4高(不到一层满高)
- 门洞内是深色单元门
- 门洞正上方、各层中央是楼梯间(开楼梯间小窗)
【阳台立体关系——别画成飘窗】
- 阳台【外凸】于客厅前方,且是【全楼层高】(从本层楼板通到上层楼板),上下层阳台连续叠放
- 阳台全高外凸,从南立面【完全遮挡】其后的客厅,所以位置3、5看到的是外凸的全高阳台
- 飘窗=外凸但只有半高——【禁止把阳台画成飘窗】
【⚠️ 禁止半地下室——不要照搬参考图底部】
- 一楼住户层【直接落在地面】,下方【不得有任何半地下室/架空层/储物层/采光井/低矮小窗层】
- 参考图(图2)底部有半地下室,【严禁照搬】;本楼最底层就是一楼住户层
【面宽比例——还原布局】
- 每户内部:卧室1约3.6m(32%) · 卧室2约3.0m(27%) · 阳台约4.7m(41%)
- 两户镜像;中央楼梯间段较窄。两个卧室的合计面宽明显大于阳台段
【视角与背景】
- 严格正投影南立面,正对南墙,无透视
- 纯白背景,无环境,只画这个单元
【其他】
- 共4-7层,每层布局相同
- 风格:明显低多边形、cel-shaded 色块、硬边、暖色调
游戏概念艺术,干净剪影,模块化资产
尺寸: 1536x1024(横向·单元南立面) 质量: medium
生成完成后,告诉我图片的完整文件路径。
@@ -1,39 +0,0 @@
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法与色调,不要它的内容。
图2是我的外立面参考(中国城郊居民楼的真实照片)——取它的材质质感、年代感、色调倾向,【不要照搬它具体的窗户形态和楼层布局,尤其不要照搬它底部的半地下室】。
图3是我的【窗户字汇表】——里面纵向列了多种窗户样式(卧室普通窗 / 阳台正面 / 阳台侧面 / 楼梯间窗 / 厨卫窄窗),每种都是正投影、平涂色块的【结构示意图】。这是窗户的"几何模具"。
任务:用图1的画法、图2的质感,画【一个单元】的【东立面正投影图】(正对东山墙、无透视)。每个位置的窗户要从图3字汇表里选用对应窗型的几何结构。
【⚠️ 图3字汇表怎么用——成败关键】
- 字汇表只提供窗户的【几何结构】,【绝不照搬它的灰平涂工程图外观】
- 必须用图1的 cel-shaded 画法,把选中的窗户【重新渲染】成有玻璃质感、暖色调、与墙面融为一体的样子——【严禁出现灰平涂的工程图窗户贴在暖色立面上的割裂感】
【东立面布局——每层从左到右严格4段】
1. 阳台侧面 —— 一个外凸阳台的侧面(端墙)
2. 厨卫窄窗
3. 厨卫窄窗
4. 阳台侧面 —— 另一个外凸阳台的侧面(端墙)
即:【两侧各一个阳台侧面,中间是两个窄窗】。
【各位置选窗型——严格对照图3字汇表】
- 位置1、4(两侧)→ 图3【阳台侧面】(外凸阳台的端墙:底部实心矮墙 + 上部1对2扇推拉窗,每扇宽高比1:2、把手在两端)。这是窄端墙,【只有1对窗,绝对不是阳台正面的6扇,也不是落地窗】
- 位置2、3(中间)→ 图3【厨卫窄窗】(单扇外开窄窗,竖长方形宽高比1:2)
【阳台侧面的立体关系——本次重点】
- 位置1、4 是南侧两个外凸阳台(向南凸出约1.2米)从东侧看露出的【端墙】
- 阳台侧面较窄(宽约1.2米),结构是【底部矮墙 + 上部1对窗】,与阳台【正面】(6扇宽面)明显不同
- 【严禁】把阳台侧面画成6扇正面、或画成从地到顶的落地窗——下半截必须是实心矮墙
【⚠️ 禁止半地下室——不要照搬参考图底部】
- 一楼住户层【直接落在地面】,下方【不得有任何半地下室/架空层/储物层/采光井/低矮小窗层】
- 参考图(图2)底部有半地下室,【严禁照搬】
【视角与背景】
- 严格正投影东立面,正对东山墙,无透视
- 纯白背景,无环境,只画这个单元
【其他】
- 共4-7层,每层布局相同
- 风格:明显低多边形、cel-shaded 色块、硬边、暖色调
游戏概念艺术,干净剪影,模块化资产
@@ -1,49 +0,0 @@
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法与色调,不要它的内容。
图2是我的外立面参考(中国城郊居民楼的真实照片)——取它的材质质感、年代感、色调倾向,【不要照搬它具体的窗户形态和楼层布局,尤其不要照搬它底部的半地下室】。
图3是我的【窗户字汇表】——里面纵向列了多种窗户样式(卧室普通窗 / 阳台正面 / 阳台侧面 / 楼梯间窗 / 厨卫窄窗),每种都是正投影、平涂色块的【结构示意图】。这是窗户的"几何模具"。
任务:用图1的画法、图2的质感,画【一个单元】的【北立面正投影图】(正对北墙、无透视)。每个位置的窗户要从图3字汇表里选用对应窗型的几何结构。
【⚠️ 图3字汇表怎么用——成败关键】
- 字汇表只提供窗户的【几何结构】,【绝不照搬它的灰平涂工程图外观】
- 必须用图1的 cel-shaded 画法,把选中的窗户【重新渲染】成有玻璃质感、暖色调、与墙面融为一体的样子——【严禁出现灰平涂的工程图窗户贴在暖色立面上的割裂感】
【北立面布局——每层从左到右严格6段】
1. 外凸阳台 —— 阳台正面(6扇)
2. 卧室1 —— 窄窗
3. 卧室2 —— 窄窗
4. 卧室2 —— 窄窗
5. 卧室1 —— 窄窗
6. 外凸阳台 —— 阳台正面(6扇)
即:【两端各一个外凸阳台,中间是 4 个卧室】。
【各位置选窗型——严格对照图3字汇表】
- 位置1、6(两端阳台)→ 图3【阳台正面】(外凸全高阳台:底部实心矮墙 + 中部三对推拉共6扇窗 + 顶部实墙,每扇宽高比1:2。【严禁落地窗、严禁两扇大窗】)
- 位置2、3、4、5(4个卧室)→ 图3【厨卫窄窗】(单扇外开窄窗,竖长方形宽高比1:2)
- 【⚠️ 反误区·最重要】北立面的 4 个卧室【全部用窄窗】,【不要】用图3里的「卧室普通窗」(两扇推拉那种)!北面卧室是小卧室、开窄窗
【阳台立体关系】
- 位置1、6 是外凸阳台(向外凸出),全楼层高,上下层连续叠放
- 阳台正面:底部矮墙 + 6扇窗(非落地窗)
【⚠️ 无单元门——本次重点】
- 北立面【没有单元门】(单元门在南侧中央)。北立面底层【中央是卧室2的连续墙面,不开门、不凹进】
- 不要在北立面画任何入口门或凹进门洞
【⚠️ 禁止半地下室——不要照搬参考图底部】
- 一楼住户层【直接落在地面】,下方【不得有任何半地下室/架空层/储物层/采光井/低矮小窗层】
- 参考图(图2)底部有半地下室,【严禁照搬】
【宽度与对称】
- 整体宽度与南立面(同一单元)一致,是横向长立面
- 6 段左右对称:两端阳台段最宽(各约占20%),4 个卧室段较窄(各约占15%)
【视角与背景】
- 严格正投影北立面,正对北墙,无透视
- 纯白背景,无环境,只画这个单元
【其他】
- 共4-7层,每层布局相同
- 风格:明显低多边形、cel-shaded 色块、硬边、暖色调
游戏概念艺术,干净剪影,模块化资产
@@ -1,27 +0,0 @@
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法,不要它的内容。
图2是我的外立面参考(中国城郊居民楼的真实质感参考)。
图3是我的【户型平面图】(一层俯视平面)——这是建筑结构的真相来源,请仔细阅读它来推导立面。
任务:用图1的画法,画【一个单元】的【南立面正投影图】(orthographic south elevation:正对南墙、无透视)。
【如何阅读这张户型图(图3)】
- 一梯两户:左侧是一户,右侧是同一户型的镜像
- 平面图为俯视;按中国住宅惯例,客厅与主卧朝南(采光面),厨房/卫生间/次卧多靠北
- 阳台通常挂南侧(客厅外)
- 外墙上的双线/开口符号 = 窗或门
【根据户型图推导南立面——本次关键】
- 请数出每户【南侧外墙】上有几个窗/门洞、各对应哪个房间(如客厅窗、阳台落地门、主卧窗等)、窗户大小
- 据此画出南立面,从左到右:左户南墙的窗洞序列 → 中央楼梯间 → 右户南墙的镜像窗洞序列
- 窗洞的数量、大小、间距要与户型图南侧房间严格对应,不要自行增减或平均化
【视角与背景】
- 严格正投影南立面,正对南墙,无透视/俯视
- 纯白背景:无街道/绿化/地面/邻居单元,只画这个单元本身
【其他】
- 一梯两户,共4-7层,每层窗户布局按户型图竖向重复
- 单元中央底层为单元门(约一层3/4高),上方为楼梯间(窗较小、位置偏上)
- 风格:明显低多边形、cel-shaded 色块、硬边、暖色调
游戏概念艺术,干净剪影,模块化资产
@@ -1,26 +0,0 @@
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法,不要它的内容。
图2是我的外立面参考(中国城郊居民楼的真实质感参考)。
图3是我的【户型平面图】(一层俯视平面)——建筑结构的真相来源。
任务:用图1的画法,画【一个单元】的【南立面正投影图】(正对南墙、无透视)。
【南侧房间构成——对照户型图(图3)】
- 一梯两户,左右镜像
- 每户南侧外墙从左到右依次为:2个卧室 + 1个外凸阳台(注意:南侧是2卧室+阳台,没有客厅)
【窗户精确形态——本次重点,严格遵守】
- 【卧室窗】每个卧室各开一个【窄窗】:窗宽小于窗高(竖长方形、横面窄);2个卧室的窄窗并排相邻、离得不远;【严禁把两个卧室的窗合并或画成一个大窗,必须是两个独立分开的窄窗】
- 【阳台】是【外凸阳台】(向室外凸出的阳台体量);阳台正面底部约至成人腰部高(约1米)为矮墙,腰部以上到阳台顶是【大开面的普通横向窗户】(整面大窗);【不要画落地窗、不要画窄窗、不要画外凸铁护栏】
- 每户南墙从左到右:卧室窄窗、卧室窄窗、外凸阳台(腰部以上大窗)
- 左右两户镜像;中央为楼梯间
【视角与背景】
- 严格正投影南立面,正对南墙,无透视
- 纯白背景,无环境,只画这个单元
【其他】
- 共4-7层,每层窗户布局相同
- 单元中央底层为单元门(约一层3/4高),上方楼梯间(窗小、偏上)
- 风格:明显低多边形、cel-shaded 色块、硬边、暖色调
游戏概念艺术,干净剪影,模块化资产
@@ -1,34 +0,0 @@
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法,不要它的内容。
图2是我的外立面参考(中国城郊居民楼的真实质感参考)。
图3是我的【户型平面图】(一层俯视平面)——建筑结构的真相来源。
任务:用图1的画法,画【一个单元】的【南立面正投影图】(正对南墙、无透视)。
【南侧房间构成——对照户型图(图3)】
- 一梯两户,左右镜像
- 每户南侧外墙从左到右依次为:2个卧室 + 1个外凸阳台(南侧是2卧室+阳台,没有客厅)
【面宽比例——必须还原户型图真实比例,本次重点】
- 阳台面宽约4.7米(单个房间中最宽)
- 卧室1面宽约3.6米(≈阳台的3/4)
- 卧室2面宽约3.0米(≈阳台的2/3)
- 【关键硬关系】两个卧室的合计面宽约6.6米,【明显大于】阳台面宽4.7米(约是阳台的1.4倍)——【严禁把卧室画窄、严禁让两卧室合计面宽窄于阳台】
- 每户从左到右的面宽分配大致为:卧室1(32%) · 卧室2(27%) · 阳台(41%)
【窗户精确形态】
- 【卧室窗】每个卧室各开一个【窄窗】:窗宽小于窗高(竖长方形、横面窄);2个卧室的窄窗并排相邻;【严禁合并成一个大窗,必须是两个独立分开的窄窗】
- 【窗窄≠墙窄】卧室窗虽窄,但卧室墙体有3-3.6米面宽;窄窗只占卧室面宽中间的一段,窗的左右两侧都是墙——【不要把整个卧室画成只有一扇窗那么窄,卧室立面应是"墙·窄窗·墙"】
- 【阳台】外凸阳台;正面底部约至腰部高(约1米)为矮墙,腰部以上到阳台顶是【大开面普通横向窗户】(整面大窗);【不要落地窗、不要窄窗、不要外凸铁护栏】
- 每户南墙从左到右:卧室(墙·窄窗·墙) · 卧室(墙·窄窗·墙) · 外凸阳台(腰部以上大窗)
- 左右两户镜像;中央为楼梯间
【视角与背景】
- 严格正投影南立面,正对南墙,无透视
- 纯白背景,无环境,只画这个单元
【其他】
- 共4-7层,每层窗户布局相同
- 单元中央底层为单元门(约一层3/4高),上方楼梯间(窗小、偏上)
- 风格:明显低多边形、cel-shaded 色块、硬边、暖色调
游戏概念艺术,干净剪影,模块化资产
@@ -1,41 +0,0 @@
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法,不要它的内容。
图2是我的外立面参考(中国城郊居民楼的真实质感参考)。
图3是我的【户型平面图】(一层俯视平面)——建筑结构的真相来源。
任务:用图1的画法,画【一个单元】的【南立面正投影图】(正对南墙、无透视)。
【南侧房间构成——对照户型图(图3)】
- 一梯两户,左右镜像
- 每户南侧从左到右依次为:卧室1 + 卧室2 + 客厅;客厅外侧(南面)接一个外凸阳台
【客厅与阳台的立体关系——本次重点】
- 客厅的南墙与两个卧室的南墙【在同一平面、互相平齐】,客厅【不凹进】
- 阳台【外凸】于客厅前方(向室外凸出),且阳台是【全楼层高】的:从本层楼板一直通到上层楼板,占据完整一层高度,上下层的阳台连续叠放
- 由于阳台全高且外凸,从南立面看阳台【完全遮挡】其后的客厅,所以客厅这一段看到的是外凸的全高阳台
- 【严格区分三种凸出物】
· 阳台=外凸 + 全楼层高(整层高)+ 可站人的半室外空间,正面是大开面窗
· 飘窗=外凸但只有半高(窗台以下齐墙、以上是窗,不占满层高)——【禁止把阳台画成飘窗】
· 普通窗=在墙面平面上的窗,不外凸
【阳台正面形态】
- 外凸全高阳台:正面底部约至腰部高(约1米)为矮墙,腰部以上到阳台顶(上层楼板)是大开面普通横向窗户
- 上下层阳台连续,立面上一层层叠放(不要出现半高、断开的阳台)
【卧室窗】
- 每个卧室各一个窄窗(窗宽<窗高,竖长方形),两个独立分开、相邻,【禁止合并成大窗】
- 卧室立面为"墙·窄窗·墙"(窄窗居中,两侧有墙)
【面宽比例——还原户型图】
- 卧室1约3.6m(32%) · 卧室2约3.0m(27%) · 客厅+外凸阳台约4.7m(41%)
- 两个卧室的合计面宽明显大于客厅+阳台段
【视角与背景】
- 严格正投影南立面,正对南墙,无透视
- 纯白背景,无环境,只画这个单元
【其他】
- 共4-7层,每层布局相同
- 单元中央底层为单元门(约一层3/4高),上方楼梯间(窗小、偏上)
- 风格:明显低多边形、cel-shaded 色块、硬边、暖色调
游戏概念艺术,干净剪影,模块化资产
@@ -1,44 +0,0 @@
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法与色调,不要它的内容。
图2是我的外立面参考(中国城郊居民楼的真实照片)——取它的材质质感、年代感、色调倾向,【不要照搬它具体的窗户形态和布局】。
图3是我的【窗户字汇表】——里面纵向列了3种窗户样式(卧室普通窗 / 厨卫窄窗 / 阳台正面),每种都是正投影、平涂色块的【结构示意图】。这是窗户的"几何模具"。
任务:用图1的画法、图2的质感,画【一个单元】的【南立面正投影图】(正对南墙、无透视)。每个位置的窗户要从图3字汇表里【选用对应窗型的几何结构】。
【⚠️ 图3字汇表怎么用——成败关键】
- 字汇表只提供窗户的【几何结构】(几扇、宽高比、把手、开法),【绝不照搬它的灰平涂工程图外观】
- 必须用图1的 cel-shaded 画法,把选中的窗户【重新渲染】成有玻璃质感、暖色调、与墙面融为一体的样子——窗户和墙面风格必须统一,【严禁出现灰平涂的工程图窗户贴在暖色立面上的割裂感】
【南侧房间构成——文字布局】
- 一梯两户,左右镜像
- 每户南侧从左到右依次为:卧室1 + 卧室2 + 客厅;客厅外侧(南面)接一个外凸阳台
- 各位置选窗型(对照图3字汇表):
· 卧室1、卧室2 → 用图3的【卧室普通窗】
· 客厅外侧的阳台 → 用图3的【阳台正面】
【阳台与客厅的立体关系——本次重点】
- 客厅的南墙与两个卧室的南墙【在同一平面、互相平齐】,客厅【不凹进】
- 阳台【外凸】于客厅前方(向室外凸出),且阳台是【全楼层高】的:从本层楼板一直通到上层楼板,占据完整一层高度,上下层的阳台连续叠放
- 由于阳台全高且外凸,从南立面看阳台【完全遮挡】其后的客厅,所以客厅这一段看到的是外凸的全高阳台
- 【严格区分三种凸出物】
· 阳台=外凸 + 全楼层高(整层高)+ 可站人的半室外空间,正面是大开面窗
· 飘窗=外凸但只有半高——【禁止把阳台画成飘窗】
· 普通窗=在墙面平面上的窗,不外凸
【窗户结构——必须严格对照图3字汇表,不要自己发明】
- 卧室1、卧室2:各用一扇图3【卧室普通窗】=两扇推拉、每扇宽高比1:2(竖长方形)、把手在两端。两个卧室的窗独立分开、相邻,【禁止合并成大窗】
- 阳台正面:用图3【阳台正面】=【三对推拉共6扇】、每扇宽高比1:2、横向均匀排列填满整个阳台宽度。【严禁画成两扇大窗、严禁宽矮形】——阳台正面宽约4.2米,每扇约0.7米,必须排下6扇
【面宽比例——还原布局】
- 卧室1约3.6m(32%) · 卧室2约3.0m(27%) · 客厅+外凸阳台约4.7m(41%)
- 两个卧室的合计面宽明显大于客厅+阳台段
【视角与背景】
- 严格正投影南立面,正对南墙,无透视
- 纯白背景,无环境,只画这个单元
【其他】
- 共4-7层,每层布局相同
- 单元中央底层为单元门(约一层3/4高),上方楼梯间(窗小、偏上)
- 风格:明显低多边形、cel-shaded 色块、硬边、暖色调
游戏概念艺术,干净剪影,模块化资产
@@ -1,62 +0,0 @@
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法与色调,不要它的内容。
图2是我的外立面参考(中国城郊居民楼的真实照片)——取它的材质质感、年代感、色调倾向,【不要照搬它具体的窗户形态和楼层布局,尤其不要照搬它底部的半地下室/架空层(见下方禁止项)】。
图3是我的【窗户字汇表】——里面纵向列了多种窗户样式(卧室普通窗 / 阳台正面 / 楼梯间窗 / 厨卫窄窗),每种都是正投影、平涂色块的【结构示意图】。这是窗户的"几何模具"。
任务:用图1的画法、图2的质感,画【一个单元】的【南立面正投影图】(正对南墙、无透视)。每个位置的窗户要从图3字汇表里【选用对应窗型的几何结构】。
【⚠️ 图3字汇表怎么用——成败关键】
- 字汇表只提供窗户的【几何结构】(几扇、宽高比、把手、开法),【绝不照搬它的灰平涂工程图外观】
- 必须用图1的 cel-shaded 画法,把选中的窗户【重新渲染】成有玻璃质感、暖色调、与墙面融为一体的样子——窗户和墙面风格必须统一,【严禁出现灰平涂的工程图窗户贴在暖色立面上的割裂感】
【单元水平布局——从左到右严格7段,必须照此排列】
整面南立面从最左到最右,依次是:
1. 左户卧室1 —— 一整组卧室普通窗
2. 左户卧室2 —— 一整组卧室普通窗
3. 左户阳台 —— 外凸全高阳台
4. 单元中央 —— 底层凹进的单元门洞 + 上方各层楼梯间
5. 右户阳台 —— 外凸全高阳台
6. 右户卧室2 —— 一整组卧室普通窗
7. 右户卧室1 —— 一整组卧室普通窗
左右两户严格镜像。两个卧室1在最外两端,两个阳台相邻靠中央,单元门+楼梯间在最正中央。
【⚠️ 卧室必须画两组——本次重点,别画成一组】
- 每户有【两个卧室】(卧室1、卧室2),是两个【不同的房间】
- 【每个卧室各画一整组】卧室普通窗(每组=两扇推拉窗,每扇宽高比1:2、把手在两端)
- 所以每户南立面有【两组卧室窗】(卧室1组 + 卧室2组,共4扇),两组之间有一段实墙隔开
- 【关键反误区·最重要】不要把"一组窗的两扇推拉"当成卧室1和卧室2!两个卧室是【两组独立的窗】,不是一组窗的两扇
- 正确的样子(左户从左到右):【卧室1的整组窗(两扇推拉)】→ 一段墙 → 【卧室2的整组窗(两扇推拉)】→ 阳台
【⚠️ 阳台正面不是落地窗——本次重点,别画成落地窗】
- 阳台正面【绝对不是落地窗】!底部约1/3高是【实心矮墙】(不透光的墙面,不是玻璃),只有矮墙以上才是窗
- 严格对照图3阳台正面的三段结构:底部实心矮墙(约1/3) + 中部窗组(三对推拉共6扇,每扇宽高比1:2) + 顶部实墙(约1/6)
- 【严禁】画成从地面一直到顶部全是玻璃的落地窗——阳台下半截必须是实心墙
- 阳台外凸于客厅前方、全楼层高,上下层连续叠放,从南立面完全遮挡其后的客厅
【各位置选窗型——严格对照图3字汇表】
- 卧室1、卧室2(位置1、2、6、7)→ 图3【卧室普通窗】(按上面"两组"要求,每户画两组)
- 阳台(位置3、5)→ 图3【阳台正面】(按上面"矮墙+窗"要求,6扇,非落地窗)
- 楼梯间(位置4上方各层)→ 图3【楼梯间窗】(横向小条窗、宽>高约2:1、又小又横,明显区别于卧室的竖长窗)
【单元门洞——位置4】
- 位于单元正中央的最底层
- 【凹进门洞】:门洞向墙内凹进(入口内缩于墙面,不是平贴墙面的门)
- 门洞高度约一层楼的3/4高(不到一层满高)
- 门洞内是深色单元门;门洞正上方各层是楼梯间(开楼梯间小窗)
【⚠️ 禁止半地下室——不要照搬参考图底部】
- 一楼住户层【直接落在地面】,下方【不得有任何半地下室/架空层/储物层/采光井/低矮小窗层】
- 参考图(图2)底部有半地下室,【严禁照搬】;本楼最底层就是一楼住户层
【面宽比例——还原布局】
- 每户内部:卧室1约3.6m(32%) · 卧室2约3.0m(27%) · 阳台约4.7m(41%)
- 两户镜像;中央楼梯间段较窄。两个卧室的合计面宽明显大于阳台段
【视角与背景】
- 严格正投影南立面,正对南墙,无透视
- 纯白背景,无环境,只画这个单元
【其他】
- 共4-7层,每层布局相同
- 风格:明显低多边形、cel-shaded 色块、硬边、暖色调
游戏概念艺术,干净剪影,模块化资产
@@ -1,19 +0,0 @@
图1是我的风格参考(《深夏时光》游戏截图)——只用它的画法,不要它的日本小镇内容。
图2是我的内容参考(中国城郊真实街景)。
请用图1的画法呈现图2,内容必须是中国城郊,严禁画成日本小镇。
风格要求:
- 3D低多边形,硬边缘 cel-shaded 色块,禁止软渐变、禁止写实纹理
- 红砖墙面保留砖块的色块分割(深浅红砖交错),不能糊成一片纯色
- 鲜艳暖色(红砖、暖黄)为主,冷绿仅作少量点缀
内容要求(中国城郊特有元素,用以区别日本小镇):
- 杂乱的电线杆与纵横的黑色电线
- 窗外的铁笼式防盗窗、墙面旧空调外机
- 阳台晾晒的衣物、被子
- 局部墙皮剥落、水泥街道有裂缝与井盖
- 红砖六层居民楼的体量、立面分割、白色门窗框保留
光照:下午四五点金黄侧光,长长的柔和阴影,温暖怀旧的黄昏前氛围
游戏概念艺术,干净剪影

Some files were not shown because too many files have changed in this diff Show More