大改版,可以针对不同项目引擎进行初始化

This commit is contained in:
Nostars Developer
2026-07-27 15:09:13 +08:00
parent 6446261c69
commit 62efb3778c
112 changed files with 11020 additions and 899 deletions

2
.cursor-init-source Normal file
View File

@@ -0,0 +1,2 @@
CursorInitGeneral source repository.
Do not run the packaged cursor-init workflow against this repository.

View File

@@ -4,6 +4,11 @@
## 目录
- `.cursor/bootstrap/`:多项目类型初始化的 catalog、manifests、惰性 payload、模板与标准库脚本。
- `.cursor/rules/common/`:所有项目始终启用的 Rules。
- `.cursor/rules/project/`:只包含初始化时所选项目类型的活动 Rules。
- `.cursor/skills/`common 与所选项目类型的活动 Skills。
- `.cursor/hooks/changelog-scan.json`:由初始化脚本按项目类型生成的 Hook 扫描策略。
- `.cursor/changelog/entries/`:开发日志 fragment 的唯一数据源。
- `.cursor/changelog/changelog-*.md`:由生成器确定性生成的只读视图。
- `.cursor/changelog/tools/`:生成、迁移、合并导入和 Git hooks 安装工具。
@@ -13,6 +18,24 @@
- `.cursor/profile/`:项目画像及其变更日志。
- `.cursor/plans/`:实施计划。
## 多类型初始化
支持类型不能从文档猜测,必须由 catalog 动态返回:
```bash
python .cursor/bootstrap/tools/cursor_init.py --list-types
```
初始化或重置使用 dry-run digest 保护:
```bash
python .cursor/bootstrap/tools/cursor_init.py --dry-run --project-type general \
--set shell=powershell --set changelog-author=<handle>
```
完整流程、安全边界和类型就绪门槛见 `.cursor/skills/cursor-init/`。未选类型只保存在
`.cursor/bootstrap/types/`,不会进入 Cursor 活动 Rule/Skill 路径。
## 开发日志
每条日志保存为 `.cursor/changelog/entries/<author>/<id>.md`。不要手改四个生成视图:

View File

@@ -0,0 +1,26 @@
{
"schema_version": 1,
"skill_version": 5,
"common_manifest": "common.manifest.json",
"skills_catalog": "skills.json",
"types": [
{
"id": "general",
"label": "通用",
"status": "ready",
"manifest": "types/general/manifest.json"
},
{
"id": "godot",
"label": "Godot",
"status": "ready",
"manifest": "types/godot/manifest.json"
},
{
"id": "roblox",
"label": "Roblox",
"status": "ready",
"manifest": "types/roblox/manifest.json"
}
]
}

View File

@@ -0,0 +1,196 @@
{
"schema_version": 1,
"id": "common",
"version": 1,
"bootstrap_files": [
"tools/cursor_init.py",
"tests/test_cursor_init.py"
],
"canonical_files": [
".cursor/hooks.json",
".cursor/local-env.example.json",
".cursor/README.md",
".cursor/README.html",
".cursor/rules/common/baserule.mdc",
".cursor/rules/common/changelog-recall.mdc",
".cursor/rules/common/cursor-init-recall.mdc",
".cursor/rules/common/deferred-recall.mdc",
".cursor/rules/common/dependency-governance.mdc",
".cursor/rules/common/distillery-recall.mdc",
".cursor/rules/common/epee-orchestrator.mdc",
".cursor/rules/common/pitfall-recall.mdc",
".cursor/rules/common/profile-recall.mdc",
".cursor/rules/project/.gitkeep",
".cursor/skills/cursor-init/SKILL.md",
".cursor/skills/cursor-init/reference.md",
".cursor/skills/deferred-decisions/SKILL.md",
".cursor/skills/dev-changelog/SKILL.md",
".cursor/skills/epee-orchestrator/SKILL.md",
".cursor/skills/epee-orchestrator/registry.md",
".cursor/skills/html-doc/SKILL.md",
".cursor/skills/html-doc/assets/annotate.js",
".cursor/skills/html-doc/assets/theme.css",
".cursor/skills/html-doc/templates/skeleton.html",
".cursor/skills/pitfall-journal/SKILL.md",
".cursor/skills/problem-distillery/SKILL.md",
".cursor/skills/profile-memory/SKILL.md",
".cursor/hooks/README.md",
".cursor/hooks/run-hook.ps1",
".cursor/hooks/session-init.ps1",
".cursor/hooks/session-init.sh",
".cursor/hooks/check-changelog.ps1",
".cursor/hooks/check-changelog.sh",
".cursor/hooks/changelog-scan.json",
".cursor/hooks/tests/test_check_changelog.py",
".cursor/changelog/tools/changelog_build.py",
".cursor/changelog/tools/migrate_changelog.py",
".cursor/changelog/tools/install-git-hooks.ps1",
".cursor/changelog/tools/install-git-hooks.sh",
".cursor/changelog/tools/merge-import.ps1",
".cursor/changelog/tools/merge-import.sh",
".cursor/changelog/tools/git-hooks/pre-commit",
".cursor/changelog/tools/git-hooks/post-merge",
".cursor/changelog/changelog-full.md",
".cursor/changelog/changelog-recent.md",
".cursor/changelog/changelog-headlines.md",
".cursor/changelog/changelog-by-author.md",
".cursor/changelog/entries/.gitkeep",
".cursor/deferred/registry.md",
".cursor/pitfalls/pitfalls.md",
".cursor/distillery/problems.md",
".cursor/distillery/insights.md",
".cursor/distillery/golden-rules.md",
".cursor/profile/project-profile.md",
".cursor/profile/project-profile-log.md"
],
"skills": [
"cursor-init",
"deferred-decisions",
"dev-changelog",
"epee-orchestrator",
"pitfall-journal",
"problem-distillery",
"profile-memory",
"html-doc"
],
"local_env_fields": [
{
"key": "shell",
"example": "powershell",
"default": "powershell",
"required": true,
"prompt": "使用的 shellpowershell、pwsh、bash 或 zsh",
"comment_key": "_shell_comment",
"comment": "Windows 可用 powershell 或 pwsh使用 Git Bash 时可填 bash。macOS/Linux 通常填 bash 或 zsh。"
},
{
"key": "changelog-author",
"example": "<your-changelog-handle>",
"default": "",
"required": true,
"prompt": "稳定的 changelog 作者 handle",
"comment_key": "_changelog_author_comment",
"comment": "用于 changelog 条目的作者标识,例如团队账号或个人 handle。"
}
],
"scan": {
"included_names": [],
"included_extensions": [
".json",
".py",
".ps1",
".sh"
],
"excluded_directories": [
".git",
".cursor",
".godot",
".vscode",
"node_modules",
"__pycache__",
"venv",
".venv",
"dist",
"build",
"out",
"bin",
"obj",
"export",
"exports",
"coverage",
"tmp",
"temp"
]
},
"gitignore_fragments": [
"fragments/common.gitignore"
],
"gitattributes_fragments": [
"fragments/common.gitattributes"
],
"reset_files": [
{
"source": "templates/changelog/changelog-full.md",
"target": ".cursor/changelog/changelog-full.md"
},
{
"source": "templates/changelog/changelog-recent.md",
"target": ".cursor/changelog/changelog-recent.md"
},
{
"source": "templates/changelog/changelog-headlines.md",
"target": ".cursor/changelog/changelog-headlines.md"
},
{
"source": "templates/changelog/changelog-by-author.md",
"target": ".cursor/changelog/changelog-by-author.md"
},
{
"source": "templates/deferred/registry.md",
"target": ".cursor/deferred/registry.md"
},
{
"source": "templates/pitfalls/pitfalls.md",
"target": ".cursor/pitfalls/pitfalls.md"
},
{
"source": "templates/distillery/problems.md",
"target": ".cursor/distillery/problems.md"
},
{
"source": "templates/distillery/insights.md",
"target": ".cursor/distillery/insights.md"
},
{
"source": "templates/distillery/golden-rules.md",
"target": ".cursor/distillery/golden-rules.md"
},
{
"source": "templates/profile/project-profile.md",
"target": ".cursor/profile/project-profile.md"
},
{
"source": "templates/profile/project-profile-log.md",
"target": ".cursor/profile/project-profile-log.md"
}
],
"cleanup_globs": [
".cursor/changelog/entries/**/*.md",
".cursor/changelog/_backup/**/*",
".cursor/_init-backup/**/*",
".cursor/**/_backup/**/*",
".cursor/**/*.bak",
".cursor/**/*.backup",
".cursor/**/__pycache__/**/*",
".cursor/**/*.pyc",
".cursor/**/*.pyo",
".cursor/**/*.cache",
".cursor/**/.cache/**/*",
".cursor/changelog/.changelog-ack",
".cursor/changelog/.session-start",
".cursor/_gitignore-preview",
".cursor/**/*.tmp",
".cursor/**/*.temp",
".cursor/debug-*.log"
]
}

View File

@@ -0,0 +1,17 @@
# Changelog 视图由多个 fragment 生成;并行分支合并时保留双方新增行。
.cursor/changelog/changelog-full.md text eol=lf merge=union
.cursor/changelog/changelog-headlines.md text eol=lf merge=union
.cursor/changelog/changelog-recent.md text eol=lf merge=union
.cursor/changelog/changelog-by-author.md text eol=lf merge=union
# 跨平台脚本、配置和文档统一使用 LF。
*.ps1 text eol=lf
*.sh text eol=lf
*.py text eol=lf
*.json text eol=lf
*.md text eol=lf
*.mdc text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.cfg text eol=lf

View File

@@ -0,0 +1,55 @@
# === Cursor runtime ===
.cursor/local-env.json
.cursor/changelog/.changelog-ack
.cursor/changelog/.session-start
.cursor/_init-backup/
.cursor/_gitignore-preview
# .cursor/.init-done 是需提交的完成标记,不得忽略
!.cursor/.init-done
# === OS ===
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
Desktop.ini
$RECYCLE.BIN/
.DS_Store
._*
.Spotlight-V100
.Trashes
*~
.fuse_hidden*
.directory
.Trash-*
# === IDE ===
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.idea/
*.iml
# === Python ===
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
.venv/
venv/
# === Common build output ===
/build/
/dist/
/out/
# === Custom ===
*.log
*.tmp

View File

@@ -0,0 +1,167 @@
{
"schema_version": 1,
"skills": [
{
"id": "cursor-init",
"scope": "common",
"type": "基础设施",
"target": ".cursor/skills/cursor-init",
"capability": "按机读 catalog 与 manifest 安全初始化或重置 Cursor 基础设施,并校验活动 Rules、Skills、Hooks 与项目类型配置",
"triggers": "首次缺少 .cursor/.init-done、初始化 cursor、cursor init、重置 cursor、补 gitignore",
"output": "通过验证的 common 基线、所选项目类型活动文件、本机配置、Git managed blocks 与 .cursor/.init-done"
},
{
"id": "deferred-decisions",
"scope": "common",
"type": "基础设施",
"target": ".cursor/skills/deferred-decisions",
"capability": "记录和追踪延期的技术方案或决策,并在关联任务出现时提醒",
"triggers": "以后再做、先不做、defer、延期方案管理、延期回顾",
"output": ".cursor/deferred/registry.md 条目及状态更新"
},
{
"id": "dev-changelog",
"scope": "common",
"type": "基础设施",
"target": ".cursor/skills/dev-changelog",
"capability": "以一条一文件的 fragment 记录开发改动,并确定性重建完整、近期、概要和作者四个视图",
"triggers": "实质代码或配置改动完成后、开发日志、changelog、最近改了什么、回顾改动",
"output": ".cursor/changelog/entries/<author>/<id>.md 与四个生成视图"
},
{
"id": "epee-orchestrator",
"scope": "common",
"type": "基础设施",
"target": ".cursor/skills/epee-orchestrator",
"capability": "匹配已有 Skill、发现可复用工作流缺口并维护与活动 Skill 集合一致的 Registry",
"triggers": "手动配置密集、重复模式明确、步骤繁多、Skill 缺口发现、Registry 同步",
"output": "Skill 分流或创建建议,以及由 skills.json 生成的 registry.md"
},
{
"id": "pitfall-journal",
"scope": "common",
"type": "基础设施",
"target": ".cursor/skills/pitfall-journal",
"capability": "记录非显而易见问题的症状、根因、解法和防御措施,并在同类问题出现时检索",
"triggers": "debug 完成、问题反复出现、踩坑、之前遇到过、进入 Debug mode",
"output": ".cursor/pitfalls/pitfalls.md 条目或匹配到的既有经验"
},
{
"id": "problem-distillery",
"scope": "common",
"type": "基础设施",
"target": ".cursor/skills/problem-distillery",
"capability": "追踪反复未解决的问题,蒸馏方法论,并把经验证的 insight 升级为 Golden Rule",
"triggers": "还有问题、没解决、还是一样、同一问题尝试两次以上、蒸馏、总结经验",
"output": ".cursor/distillery/problems.md、insights.md、golden-rules.md"
},
{
"id": "profile-memory",
"scope": "common",
"type": "个人级",
"target": ".cursor/skills/profile-memory",
"capability": "检测并经确认持久化用户偏好与项目画像,支持查看、修改、删除和溯源",
"triggers": "持久偏好或项目决策出现、画像、profile、我的偏好、查看项目画像",
"output": "用户与项目的精简 Profile 及详细 Log"
},
{
"id": "html-doc",
"scope": "common",
"type": "个人级",
"target": ".cursor/skills/html-doc",
"capability": "生成统一深色主题的单文件 HTML 文档,并可选提供本地暂存和回传批注功能",
"triggers": "HTML 参考手册、说明页、报告、清单、统一 HTML 样式、可批注文档",
"output": "内联 CSS/JS 的自包含 .html 文档"
},
{
"id": "roblox-project-initializer",
"scope": "roblox",
"type": "Roblox 项目级",
"target": ".cursor/skills/roblox-project-initializer",
"capability": "为 Roblox 项目形成工程分档、架构边界、工具选择和初始化清单,不自动生成脚手架",
"triggers": "规划或评审 Roblox 项目结构、Rojo 工作流、数据与网络边界、初始化清单",
"output": "经确认的 Roblox 项目初始化与架构规划"
},
{
"id": "roblox-vibe-project-framework",
"scope": "roblox",
"type": "Roblox 项目级",
"target": ".cursor/skills/roblox-vibe-project-framework",
"capability": "按服务端权威和显式运行时边界,以小型垂直切片实现、扩展或重构 Roblox 工程",
"triggers": "创建 Roblox 脚手架、实现 feature slice、维护性审查、架构重构",
"output": "最小可验证的 Roblox 功能切片、源码改动与验证结果"
},
{
"id": "roblox-ui-producer",
"scope": "roblox",
"type": "Roblox 项目级",
"target": ".cursor/skills/roblox-ui-producer",
"capability": "从 UI Design Brief 和框架决策门开始,设计并实现兼顾移动端、生命周期与性能的 Roblox UI",
"triggers": "Roblox UI、HUD、菜单、移动端适配、Imperative UI、React-Lua 或 Hybrid 选型",
"output": "UI 设计说明、框架决策、实现计划、源码与验证清单"
},
{
"id": "building-3d-objects",
"scope": "roblox",
"type": "Roblox 项目级",
"target": ".cursor/skills/building-3d-objects",
"capability": "收束单体 3D 需求并通过 Parts、CSG、Luau 或结构计划构建和验证 Roblox 物件",
"triggers": "Roblox 物件、道具、家具、房间、CSG、Union、Subtract、Part 建模",
"output": "Design Brief、实例结构、Luau 构建方案、验证与证据化复盘"
},
{
"id": "building-maps",
"scope": "roblox",
"type": "Roblox 项目级",
"target": ".cursor/skills/building-maps",
"capability": "规划并分阶段构建 Roblox 多区域地图、竞技场、大厅或关卡,保持可通行性和地标层次",
"triggers": "Roblox 地图、竞技场、城镇、大厅、关卡、多区域环境",
"output": "地图拓扑、分阶段实例结构、验证结果与复盘候选"
},
{
"id": "roblox-camera-cinematics",
"scope": "roblox",
"type": "Roblox 项目级",
"target": ".cursor/skills/roblox-camera-cinematics",
"capability": "设计数据驱动的 Roblox CameraEvent、Scriptable 相机、跳过与恢复流程和舒适性验证",
"triggers": "Roblox 镜头、过场、CameraEvent、Scriptable Camera、skip、camera shake",
"output": "镜头事件规格、客户端实现、恢复路径与跨平台 QA 清单"
},
{
"id": "roblox-vfx-designer",
"scope": "roblox",
"type": "Roblox 项目级",
"target": ".cursor/skills/roblox-vfx-designer",
"capability": "先探测项目特效架构,再设计并集成有预算、生命周期和验证路径的 Roblox 客户端 VFX",
"triggers": "Roblox VFX、粒子、Beam、Trail、法阵、冲击波、光环、命中特效",
"output": "VFX 分镜、实例结构、参数预算、源码集成与验证清单"
},
{
"id": "roblox-animation-producer",
"scope": "roblox",
"type": "Roblox 项目级",
"target": ".cursor/skills/roblox-animation-producer",
"capability": "为 R15 角色形成 Motion Spec、KeyframeSequence 构建方案、发布步骤和项目播放集成",
"triggers": "Roblox R15 动画、动作设计、KeyframeSequence、AnimationId、动画发布与集成",
"output": "Motion Spec、动画构建源码、Studio 发布步骤与播放验证"
},
{
"id": "roblox-commonkit-usage",
"scope": "roblox",
"type": "Roblox 条件项目级",
"target": ".cursor/skills/roblox-commonkit-usage",
"capability": "仅在目标项目实际存在 CommonKit 时审查模块目录、优先复用并约束运行时边界",
"triggers": "检测到 CommonKit 路径、包、导出或用户明确要求 CommonKit 复用与迁移",
"output": "基于目标仓库真实导出的 CommonKit 复用、迁移或去重方案"
},
{
"id": "roblox-asset-uploader",
"scope": "roblox",
"type": "Roblox 条件项目级",
"target": ".cursor/skills/roblox-asset-uploader",
"capability": "通过标准库安全 dry-run 并在用户逐次确认后调用 Roblox Open Cloud 上传资源",
"triggers": "用户明确要求上传 Roblox 图片、音频、模型、Decal 或获取 rbxassetid",
"output": "经确认上传后的 Roblox Asset ID或不联网的上传计划与诊断"
}
]
}

View File

@@ -0,0 +1,4 @@
# Dev Changelog — By Author
按作者聚合的贡献概览,便于多人协作时区分提交来源。
> 本文件由 `tools/changelog_build.py` 自动生成,请勿手改。

View File

@@ -0,0 +1,6 @@
# Dev Changelog — Full
完整的开发改动记录,按时间倒序排列。作为主动 RAG 的数据源,用户手动唤醒时读取。
> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改;新增/修改请编辑 `entries/<author>/<id>.md` 后重跑生成器。
## 记录

View File

@@ -0,0 +1,4 @@
# Dev Changelog — Headlines
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。

View File

@@ -0,0 +1,5 @@
# Dev Changelog — Recent
最近 ~20 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。
> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。

View File

@@ -0,0 +1,11 @@
# Deferred Decisions Registry
## Active Items
(暂无延期方案)
---
## Completed / Cancelled Items
(暂无已完成或已废弃的方案)

View File

@@ -0,0 +1,5 @@
# Golden Rules
经过实践验证(权重 >= 5的精炼认知每次新会话自动注入。
<!-- 当条目达到权重阈值后由 Agent 自动写入 -->

View File

@@ -0,0 +1,8 @@
# Problem Distillery — Insights
从已解决的顽固问题中蒸馏出的精炼方法论。
<!-- 新条目追加在此行下方 -->
---
last_distill_date:

View File

@@ -0,0 +1,5 @@
# Problem Distillery — Problems
反复出现的顽固问题追踪记录,按时间倒序排列。
<!-- 新条目追加在此行下方 -->

View File

@@ -0,0 +1,6 @@
# Pitfall Journal
开发过程中踩过的坑,按时间倒序排列。
Agent 进入 Debug mode 或遇到运行时错误时自动检索匹配。
---

View File

@@ -0,0 +1,5 @@
# Project Profile Log
详细记录每次项目画像更新的完整上下文,按时间正序追加。
## 记录

View File

@@ -0,0 +1,9 @@
# Project Profile
## 项目定位
## 技术栈与架构
## 设计约定
## 产品方向

View File

@@ -0,0 +1,726 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "tools" / "cursor_init.py"
PROJECT_ROOT = SCRIPT_PATH.parents[3]
sys.dont_write_bytecode = True
SPEC = importlib.util.spec_from_file_location("cursor_init_under_test", SCRIPT_PATH)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"无法加载 {SCRIPT_PATH}")
CURSOR_INIT = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = CURSOR_INIT
SPEC.loader.exec_module(CURSOR_INIT)
class CursorInitTests(unittest.TestCase):
def _write_text(self, root: Path, relative: str, content: str) -> None:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="\n")
def _write_json(self, root: Path, relative: str, data: object) -> None:
self._write_text(
root,
relative,
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
)
def _fixture(self) -> tuple[tempfile.TemporaryDirectory[str], Path]:
temporary = tempfile.TemporaryDirectory()
root = Path(temporary.name)
bootstrap = ".cursor/bootstrap"
self._write_json(
root,
f"{bootstrap}/catalog.json",
{
"schema_version": 1,
"skill_version": 5,
"common_manifest": "common.manifest.json",
"skills_catalog": "skills.json",
"types": [
{
"id": "general",
"label": "通用",
"status": "ready",
"manifest": "types/general/manifest.json",
},
{
"id": "godot",
"label": "Godot",
"status": "ready",
"manifest": "types/godot/manifest.json",
},
{
"id": "roblox",
"label": "Roblox",
"status": "ready",
"manifest": "types/roblox/manifest.json",
},
],
},
)
self._write_json(
root,
f"{bootstrap}/common.manifest.json",
{
"schema_version": 1,
"id": "common",
"version": 1,
"canonical_files": [
".cursor/rules/common/base.mdc",
".cursor/rules/project/.gitkeep",
".cursor/skills/epee-orchestrator/SKILL.md",
".cursor/pitfalls/pitfalls.md",
],
"skills": ["epee-orchestrator"],
"local_env_fields": [
{
"key": "shell",
"example": "powershell",
"default": "powershell",
"required": True,
"prompt": "shell",
},
{
"key": "changelog-author",
"example": "<handle>",
"default": "",
"required": True,
"prompt": "author",
},
],
"scan": {
"included_names": [],
"included_extensions": [".json"],
"excluded_directories": [".git", ".cursor"],
},
"gitignore_fragments": ["fragments/common.gitignore"],
"gitattributes_fragments": ["fragments/common.gitattributes"],
"reset_files": [
{
"source": "templates/pitfalls.md",
"target": ".cursor/pitfalls/pitfalls.md",
}
],
"cleanup_globs": [".cursor/changelog/entries/**/*.md"],
},
)
self._write_json(
root,
f"{bootstrap}/skills.json",
{
"schema_version": 1,
"skills": [
{
"id": "epee-orchestrator",
"scope": "common",
"type": "基础设施",
"target": ".cursor/skills/epee-orchestrator",
"capability": "测试 common Skill",
"triggers": "测试",
"output": "测试输出",
},
{
"id": "godot-helper",
"scope": "godot",
"type": "项目级",
"target": ".cursor/skills/godot-helper",
"capability": "测试 Godot Skill",
"triggers": "Godot 测试",
"output": "Godot 测试输出",
},
{
"id": "roblox-helper",
"scope": "roblox",
"type": "项目级",
"target": ".cursor/skills/roblox-helper",
"capability": "测试 Roblox Skill",
"triggers": "Roblox 测试",
"output": "测试输出",
},
],
},
)
self._write_json(
root,
f"{bootstrap}/types/general/manifest.json",
{
"schema_version": 1,
"id": "general",
"version": 1,
"readiness": {"allow_empty_payload": True},
"files": [],
"trees": [],
"skills": [],
"local_env_fields": [],
"scan": {
"included_names": [],
"included_extensions": [],
"excluded_directories": [],
},
"gitignore_fragments": [],
"gitattributes_fragments": [],
},
)
self._write_json(
root,
f"{bootstrap}/types/godot/manifest.json",
{
"schema_version": 1,
"id": "godot",
"version": 1,
"readiness": {
"allow_empty_payload": False,
"require_scan": True,
"require_gitignore": True,
},
"files": [
{
"source": "payload/rules/godot.mdc",
"target": ".cursor/rules/project/godot.mdc",
}
],
"trees": [
{
"source": "payload/skills/godot-helper",
"target": ".cursor/skills/godot-helper",
}
],
"skills": ["godot-helper"],
"local_env_fields": [
{
"key": "godot4_path",
"example": "<godot>",
"default": "",
"required": False,
"prompt": "Godot",
}
],
"scan": {
"included_names": ["project.godot"],
"included_extensions": [".gd"],
"excluded_directories": [".godot"],
},
"gitignore_fragments": ["fragments/godot.gitignore"],
"gitattributes_fragments": ["fragments/godot.gitattributes"],
},
)
self._write_json(
root,
f"{bootstrap}/types/roblox/manifest.json",
{
"schema_version": 1,
"id": "roblox",
"version": 1,
"readiness": {
"allow_empty_payload": False,
"require_scan": True,
"require_gitignore": True,
},
"files": [
{
"source": "payload/rules/roblox.mdc",
"target": ".cursor/rules/project/roblox.mdc",
}
],
"trees": [
{
"source": "payload/skills/roblox-helper",
"target": ".cursor/skills/roblox-helper",
}
],
"skills": ["roblox-helper"],
"local_env_fields": [
{
"key": "roblox_open_cloud_api_key",
"example": "<roblox-api-key>",
"default": "",
"required": False,
"sensitive": True,
"collect_at_init": False,
"prompt": "Roblox API Key",
}
],
"scan": {
"included_names": ["default.project.json"],
"included_extensions": [".luau"],
"excluded_directories": ["Packages"],
},
"gitignore_fragments": ["fragments/roblox.gitignore"],
"gitattributes_fragments": ["fragments/roblox.gitattributes"],
},
)
self._write_text(
root,
f"{bootstrap}/fragments/common.gitignore",
".cursor/local-env.json\n",
)
self._write_text(
root,
f"{bootstrap}/fragments/common.gitattributes",
"*.json text eol=lf\n",
)
self._write_text(
root,
f"{bootstrap}/templates/pitfalls.md",
"# Pitfall Journal\n\n---\n",
)
self._write_text(
root,
f"{bootstrap}/types/godot/fragments/godot.gitignore",
".godot/\n",
)
self._write_text(
root,
f"{bootstrap}/types/godot/fragments/godot.gitattributes",
"*.gd text eol=lf\n",
)
self._write_text(
root,
f"{bootstrap}/types/godot/payload/rules/godot.mdc",
"# Godot\n",
)
self._write_text(
root,
f"{bootstrap}/types/godot/payload/skills/godot-helper/SKILL.md",
"---\nname: godot-helper\ndescription: test\n---\n",
)
self._write_text(
root,
f"{bootstrap}/types/roblox/fragments/roblox.gitignore",
"Packages/\n",
)
self._write_text(
root,
f"{bootstrap}/types/roblox/fragments/roblox.gitattributes",
"*.luau text eol=lf\n",
)
self._write_text(
root,
f"{bootstrap}/types/roblox/payload/rules/roblox.mdc",
"# Roblox\n",
)
self._write_text(
root,
f"{bootstrap}/types/roblox/payload/skills/roblox-helper/SKILL.md",
"---\nname: roblox-helper\ndescription: test\n---\n",
)
self._write_text(root, ".cursor/rules/common/base.mdc", "# Base\n")
self._write_text(root, ".cursor/rules/project/.gitkeep", "")
self._write_text(
root,
".cursor/skills/epee-orchestrator/SKILL.md",
"---\nname: epee-orchestrator\ndescription: test\n---\n",
)
self._write_text(root, ".cursor/pitfalls/pitfalls.md", "# Old pitfalls\n")
self._write_text(
root,
".cursor/changelog/entries/tester/old.md",
"# Old changelog\n",
)
return temporary, root
def _plan(self, root: Path, project_type: str):
context = CURSOR_INIT.load_context(root)
plan = CURSOR_INIT.build_plan(
context,
project_type,
values={"shell": "powershell", "changelog-author": "tester"},
)
return context, plan
def _apply(self, root: Path, project_type: str):
context, plan = self._plan(root, project_type)
self.assertEqual(plan.blockers, [])
CURSOR_INIT.apply_plan(context, plan, plan.digest)
return context, plan
def test_list_types_includes_ready_roblox(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
context = CURSOR_INIT.load_context(root)
self.assertEqual(
CURSOR_INIT.list_ready_types(context),
[
{"id": "general", "label": "通用"},
{"id": "godot", "label": "Godot"},
{"id": "roblox", "label": "Roblox"},
],
)
def test_empty_roblox_bundle_cannot_be_marked_ready(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
manifest_path = root / ".cursor/bootstrap/types/roblox/manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["files"] = []
manifest["trees"] = []
manifest["skills"] = []
manifest["scan"] = {
"included_names": [],
"included_extensions": [],
"excluded_directories": [],
}
manifest["gitignore_fragments"] = []
manifest["gitattributes_fragments"] = []
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
with self.assertRaises(CURSOR_INIT.InitError):
CURSOR_INIT.load_context(root)
def test_general_apply_is_idempotent(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
context, _ = self._apply(root, "general")
second_plan = CURSOR_INIT.build_plan(
context,
"general",
values={"shell": "powershell", "changelog-author": "tester"},
)
self.assertEqual(second_plan.blockers, [])
self.assertEqual(second_plan.actions, [])
self.assertEqual(CURSOR_INIT.check_project(context), [])
def test_godot_to_general_removes_type_files(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "godot")
self.assertTrue((root / ".cursor/rules/project/godot.mdc").is_file())
self.assertTrue((root / ".cursor/skills/godot-helper/SKILL.md").is_file())
self._apply(root, "general")
self.assertFalse((root / ".cursor/rules/project/godot.mdc").exists())
self.assertFalse((root / ".cursor/skills/godot-helper/SKILL.md").exists())
scan = json.loads(
(root / ".cursor/hooks/changelog-scan.json").read_text(encoding="utf-8")
)
self.assertEqual(scan["project_type"], "general")
def test_roblox_materializes_only_for_roblox_and_switch_removes_it(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "roblox")
self.assertTrue((root / ".cursor/rules/project/roblox.mdc").is_file())
self.assertTrue(
(root / ".cursor/skills/roblox-helper/SKILL.md").is_file()
)
registry = (
root / ".cursor/skills/epee-orchestrator/registry.md"
).read_text(encoding="utf-8")
self.assertIn("roblox-helper", registry)
example = json.loads(
(root / ".cursor/local-env.example.json").read_text(encoding="utf-8")
)
self.assertEqual(
example["roblox_open_cloud_api_key"],
"<roblox-api-key>",
)
local_env = json.loads(
(root / ".cursor/local-env.json").read_text(encoding="utf-8")
)
self.assertEqual(local_env["roblox_open_cloud_api_key"], "")
self._apply(root, "general")
self.assertFalse((root / ".cursor/rules/project/roblox.mdc").exists())
self.assertFalse((root / ".cursor/skills/roblox-helper/SKILL.md").exists())
general_example = json.loads(
(root / ".cursor/local-env.example.json").read_text(encoding="utf-8")
)
self.assertNotIn("roblox_open_cloud_api_key", general_example)
def test_godot_and_roblox_cross_switches_remove_old_bundle(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "godot")
self._apply(root, "roblox")
self.assertFalse((root / ".cursor/rules/project/godot.mdc").exists())
self.assertFalse((root / ".cursor/skills/godot-helper/SKILL.md").exists())
self.assertTrue((root / ".cursor/rules/project/roblox.mdc").is_file())
self._apply(root, "godot")
self.assertFalse((root / ".cursor/rules/project/roblox.mdc").exists())
self.assertFalse((root / ".cursor/skills/roblox-helper/SKILL.md").exists())
self.assertTrue((root / ".cursor/rules/project/godot.mdc").is_file())
def test_sensitive_value_cannot_be_passed_with_set(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
context = CURSOR_INIT.load_context(root)
fields = CURSOR_INIT._all_env_fields(
context,
context.types["roblox"],
)
with self.assertRaisesRegex(CURSOR_INIT.InitError, "敏感字段"):
CURSOR_INIT._validate_input_values(
fields,
{"roblox_open_cloud_api_key": "do-not-accept"},
)
def test_sensitive_local_value_is_preserved_and_not_drift(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._write_json(
root,
".cursor/local-env.json",
{
"shell": "powershell",
"changelog-author": "tester",
"roblox_open_cloud_api_key": "local-secret",
},
)
self._apply(root, "roblox")
local_env_path = root / ".cursor/local-env.json"
local_env = json.loads(local_env_path.read_text(encoding="utf-8"))
self.assertEqual(local_env["roblox_open_cloud_api_key"], "local-secret")
local_env["roblox_open_cloud_api_key"] = "rotated-secret"
self._write_json(root, ".cursor/local-env.json", local_env)
context = CURSOR_INIT.load_context(root)
self.assertEqual(CURSOR_INIT.check_project(context, "roblox"), [])
def test_sensitive_value_rotation_does_not_change_plan_digest(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._write_json(
root,
".cursor/local-env.json",
{"roblox_open_cloud_api_key": "first-secret"},
)
context, first_plan = self._plan(root, "roblox")
self._write_json(
root,
".cursor/local-env.json",
{"roblox_open_cloud_api_key": "rotated-secret"},
)
context, rotated_plan = self._plan(root, "roblox")
self.assertEqual(first_plan.digest, rotated_plan.digest)
serialized = json.dumps(CURSOR_INIT.plan_as_dict(rotated_plan))
self.assertNotIn("first-secret", serialized)
self.assertNotIn("rotated-secret", serialized)
self.assertIn("<local-sensitive-content>", serialized)
def test_plan_digest_mismatch_is_rejected(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
context, plan = self._plan(root, "general")
with self.assertRaises(CURSOR_INIT.InitError):
CURSOR_INIT.apply_plan(context, plan, "incorrect")
self.assertFalse((root / ".cursor/.init-done").exists())
def test_foreign_rule_blocks_apply(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._write_text(root, ".cursor/rules/project/foreign.mdc", "# Foreign\n")
context, plan = self._plan(root, "general")
self.assertTrue(plan.foreign)
with self.assertRaises(CURSOR_INIT.InitError):
CURSOR_INIT.apply_plan(context, plan, plan.digest)
self.assertFalse((root / ".cursor/.init-done").exists())
def test_modified_managed_file_blocks_type_switch(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "godot")
self._write_text(
root,
".cursor/rules/project/godot.mdc",
"# User modified\n",
)
_, plan = self._plan(root, "general")
self.assertTrue(
any("拒绝删除" in blocker for blocker in plan.blockers),
plan.blockers,
)
def test_apply_resets_state_and_removes_history(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "general")
self.assertEqual(
(root / ".cursor/pitfalls/pitfalls.md").read_text(encoding="utf-8"),
"# Pitfall Journal\n\n---\n",
)
self.assertFalse(
(root / ".cursor/changelog/entries/tester/old.md").exists()
)
def test_missing_common_canonical_blocks_apply(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
(root / ".cursor/rules/common/base.mdc").unlink()
context, plan = self._plan(root, "general")
self.assertTrue(
any("缺少 common canonical" in blocker for blocker in plan.blockers),
plan.blockers,
)
with self.assertRaises(CURSOR_INIT.InitError):
CURSOR_INIT.apply_plan(context, plan, plan.digest)
self.assertFalse((root / ".cursor/.init-done").exists())
def test_skip_git_files_preserves_user_files(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._write_text(root, ".gitignore", "# user\n")
context = CURSOR_INIT.load_context(root)
plan = CURSOR_INIT.build_plan(
context,
"general",
values={"shell": "powershell", "changelog-author": "tester"},
skip_git_files=True,
)
self.assertEqual(plan.blockers, [])
CURSOR_INIT.apply_plan(context, plan, plan.digest)
self.assertEqual((root / ".gitignore").read_text(encoding="utf-8"), "# user\n")
sentinel = (root / ".cursor/.init-done").read_text(encoding="utf-8")
self.assertIn("gitignore_generated: false", sentinel)
def test_generated_text_is_utf8_without_bom_and_uses_lf(self) -> None:
temporary, root = self._fixture()
self.addCleanup(temporary.cleanup)
self._apply(root, "godot")
generated = [
".cursor/.init-done",
".cursor/.init-state.json",
".cursor/hooks/changelog-scan.json",
".cursor/local-env.example.json",
".cursor/local-env.json",
".cursor/skills/epee-orchestrator/registry.md",
".gitignore",
".gitattributes",
]
for relative in generated:
with self.subTest(path=relative):
content = (root / relative).read_bytes()
self.assertFalse(content.startswith(b"\xef\xbb\xbf"))
self.assertNotIn(b"\r\n", content)
content.decode("utf-8")
def test_repository_roblox_bundle_is_ready_clean_and_complete(self) -> None:
context = CURSOR_INIT.load_context(PROJECT_ROOT)
roblox = context.types["roblox"]
self.assertEqual(roblox.status, "ready")
self.assertEqual(len(roblox.manifest["skills"]), 10)
specs = CURSOR_INIT._expand_manifest_files(roblox)
targets = {spec.target for spec in specs}
self.assertIn(
".cursor/rules/project/roblox-luau-guardrails.mdc",
targets,
)
for skill_id in roblox.manifest["skills"]:
with self.subTest(skill=skill_id):
self.assertIn(
f".cursor/skills/{skill_id}/SKILL.md",
targets,
)
payload_root = (
PROJECT_ROOT / ".cursor/bootstrap/types/roblox/payload"
)
forbidden = ("Codex", ".codex", "xi.chen", "SwordnWizard")
for path in payload_root.rglob("*"):
if not path.is_file():
continue
with self.subTest(path=path.relative_to(PROJECT_ROOT)):
self.assertNotEqual(path.name, "openai.yaml")
content = path.read_bytes()
self.assertFalse(content.startswith(b"\xef\xbb\xbf"))
self.assertNotIn(b"\r\n", content)
text = content.decode("utf-8")
for marker in forbidden:
self.assertNotIn(marker, text)
def test_repository_payload_applies_and_cross_switches_in_temp_target(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
shutil.copytree(PROJECT_ROOT / ".cursor", root / ".cursor")
local_env_path = root / ".cursor/local-env.json"
if local_env_path.exists():
local_env_path.unlink()
for name in (".gitignore", ".gitattributes"):
source = PROJECT_ROOT / name
if source.is_file():
shutil.copy2(source, root / name)
for project_type in ("roblox", "general", "godot", "roblox"):
context = CURSOR_INIT.load_context(root)
plan = CURSOR_INIT.build_plan(
context,
project_type,
values={
"shell": "powershell",
"changelog-author": "integration-test",
},
)
self.assertEqual(plan.blockers, [], project_type)
CURSOR_INIT.apply_plan(context, plan, plan.digest)
checked_context = CURSOR_INIT.load_context(root)
self.assertEqual(
CURSOR_INIT.check_project(checked_context, project_type),
[],
project_type,
)
roblox_skill = (
root
/ ".cursor/skills/roblox-project-initializer/SKILL.md"
)
if project_type == "roblox":
self.assertTrue(roblox_skill.is_file())
example = json.loads(
(
root / ".cursor/local-env.example.json"
).read_text(encoding="utf-8")
)
self.assertEqual(
example["roblox_open_cloud_api_key"],
"<your-open-cloud-api-key>",
)
else:
self.assertFalse(roblox_skill.exists())
example = json.loads(
(
root / ".cursor/local-env.example.json"
).read_text(encoding="utf-8")
)
self.assertNotIn("roblox_open_cloud_api_key", example)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"schema_version": 1,
"id": "general",
"version": 1,
"readiness": {
"allow_empty_payload": true,
"require_scan": false,
"require_gitignore": false
},
"files": [],
"trees": [],
"skills": [],
"local_env_fields": [],
"scan": {
"included_names": [],
"included_extensions": [],
"excluded_directories": []
},
"gitignore_fragments": [],
"gitattributes_fragments": []
}

View File

@@ -0,0 +1,8 @@
# Godot 文本资源统一使用 LF。
*.gd text eol=lf
*.gdshader text eol=lf
*.tscn text eol=lf
*.tres text eol=lf
*.uid text eol=lf
*.import text eol=lf
project.godot text eol=lf

View File

@@ -0,0 +1,10 @@
# === Godot ===
.godot/
/export/
/exports/
# Godot 4 UID sidecar 与资源导入配置是项目元数据,必须提交
!*.uid
!**/*.uid
!*.import
!**/*.import

View File

@@ -0,0 +1,71 @@
{
"schema_version": 1,
"id": "godot",
"version": 1,
"readiness": {
"allow_empty_payload": false,
"require_scan": true,
"require_gitignore": true
},
"files": [
{
"source": "payload/rules/project/godot-architecture.mdc",
"target": ".cursor/rules/project/godot-architecture.mdc"
},
{
"source": "payload/rules/project/godot-assets.mdc",
"target": ".cursor/rules/project/godot-assets.mdc"
},
{
"source": "payload/rules/project/godot-data-systems.mdc",
"target": ".cursor/rules/project/godot-data-systems.mdc"
},
{
"source": "payload/rules/project/godot-gdscript-style.mdc",
"target": ".cursor/rules/project/godot-gdscript-style.mdc"
},
{
"source": "payload/rules/project/godot-scene-conventions.mdc",
"target": ".cursor/rules/project/godot-scene-conventions.mdc"
},
{
"source": "payload/rules/project/godot-testing.mdc",
"target": ".cursor/rules/project/godot-testing.mdc"
}
],
"trees": [],
"skills": [],
"local_env_fields": [
{
"key": "godot4_path",
"example": "<path-to-godot-4-executable>",
"default": "",
"required": false,
"prompt": "Godot 4 可执行文件路径(可留空)",
"comment_key": "_godot4_path_comment",
"comment": "填写本机 Godot 4 可执行文件路径或可从 PATH 解析的命令名;不要把个人绝对路径写入模板。"
}
],
"scan": {
"included_names": [
"project.godot"
],
"included_extensions": [
".gd",
".tscn",
".tres",
".res",
".gdshader",
".cfg",
".cs",
".csproj"
],
"excluded_directories": []
},
"gitignore_fragments": [
"fragments/godot.gitignore"
],
"gitattributes_fragments": [
"fragments/godot.gitattributes"
]
}

View File

@@ -35,7 +35,9 @@ func confirm(id: StringName) -> void:
## 工程完整性
- Godot 版本遵循项目声明;升级引擎或兼容特征前必须说明影响并取得确认。
- 启动、测试或导出前检查 `project.godot`、项目 README 与 `.cursor/local-env.json`
声明的 Godot 版本和可执行文件;本机配置只用于选择运行时,不能覆盖项目要求。
- 升级引擎或兼容特征前必须说明影响并取得确认。
- 修改 `project.godot`、导出预设、autoload 或输入映射前,先说明运行与构建影响。
- Godot 生成的 UID 与对应资源保持一致,不手工伪造;移动或删除资源时检查引用。
- 外部插件和第三方资源遵守 `dependency-governance`。

View File

@@ -0,0 +1,7 @@
# === Roblox / Rojo text and binary resources ===
*.lua text eol=lf
*.luau text eol=lf
*.rbxl binary
*.rbxm binary
*.rbxlx text eol=lf
*.rbxmx text eol=lf

View File

@@ -0,0 +1,8 @@
# === Roblox / Rojo generated ===
**/Packages/
**/ServerPackages/
**/ClientPackages/
**/DevPackages/
**/.lune/
**/sourcemap.json
**/test.sourcemap.json

View File

@@ -0,0 +1,164 @@
{
"schema_version": 1,
"id": "roblox",
"version": 1,
"readiness": {
"allow_empty_payload": false,
"require_scan": true,
"require_gitignore": true
},
"files": [
{
"source": "payload/rules/project/roblox-luau-guardrails.mdc",
"target": ".cursor/rules/project/roblox-luau-guardrails.mdc"
}
],
"trees": [
{
"source": "payload/skills/roblox-project-initializer",
"target": ".cursor/skills/roblox-project-initializer"
},
{
"source": "payload/skills/roblox-vibe-project-framework",
"target": ".cursor/skills/roblox-vibe-project-framework"
},
{
"source": "payload/skills/roblox-ui-producer",
"target": ".cursor/skills/roblox-ui-producer"
},
{
"source": "payload/skills/building-3d-objects",
"target": ".cursor/skills/building-3d-objects"
},
{
"source": "payload/skills/building-maps",
"target": ".cursor/skills/building-maps"
},
{
"source": "payload/skills/roblox-camera-cinematics",
"target": ".cursor/skills/roblox-camera-cinematics"
},
{
"source": "payload/skills/roblox-vfx-designer",
"target": ".cursor/skills/roblox-vfx-designer"
},
{
"source": "payload/skills/roblox-animation-producer",
"target": ".cursor/skills/roblox-animation-producer"
},
{
"source": "payload/skills/roblox-commonkit-usage",
"target": ".cursor/skills/roblox-commonkit-usage"
},
{
"source": "payload/skills/roblox-asset-uploader",
"target": ".cursor/skills/roblox-asset-uploader"
}
],
"skills": [
"roblox-project-initializer",
"roblox-vibe-project-framework",
"roblox-ui-producer",
"building-3d-objects",
"building-maps",
"roblox-camera-cinematics",
"roblox-vfx-designer",
"roblox-animation-producer",
"roblox-commonkit-usage",
"roblox-asset-uploader"
],
"local_env_fields": [
{
"key": "roblox_studio_path",
"example": "<path-to-roblox-studio>",
"default": "",
"required": false,
"collect_at_init": false,
"prompt": "Roblox Studio 可执行文件路径(按需手工填写)",
"comment_key": "_roblox_studio_path_comment",
"comment": "可选;填写本机 Roblox Studio 可执行文件路径。初始化不采集此值。"
},
{
"key": "roblox_creator_type",
"example": "<User-or-Group>",
"default": "",
"required": false,
"collect_at_init": false,
"prompt": "Open Cloud Creator 类型(按需手工填写)",
"comment_key": "_roblox_creator_type_comment",
"comment": "可选;仅允许 User 或 Group。真实值写入本机 local-env.json。"
},
{
"key": "roblox_creator_id",
"example": "<numeric-creator-id>",
"default": "",
"required": false,
"collect_at_init": false,
"prompt": "Open Cloud Creator ID按需手工填写",
"comment_key": "_roblox_creator_id_comment",
"comment": "可选;填写 User 或 Group 的数字 ID。真实值写入本机 local-env.json。"
},
{
"key": "roblox_open_cloud_api_key",
"example": "<your-open-cloud-api-key>",
"default": "",
"required": false,
"sensitive": true,
"collect_at_init": false,
"prompt": "Open Cloud API Key敏感初始化不采集",
"comment_key": "_roblox_open_cloud_api_key_comment",
"comment": "敏感;只写入被忽略的本机 local-env.json不得提交、回显或通过 --set 传入。"
},
{
"key": "roblox_universe_id",
"example": "<numeric-universe-id>",
"default": "",
"required": false,
"collect_at_init": false,
"prompt": "Roblox Universe ID按需手工填写",
"comment_key": "_roblox_universe_id_comment",
"comment": "可选;供需要 Universe 范围的 Open Cloud 工作流使用。"
},
{
"key": "roblox_place_id",
"example": "<numeric-place-id>",
"default": "",
"required": false,
"collect_at_init": false,
"prompt": "Roblox Place ID按需手工填写",
"comment_key": "_roblox_place_id_comment",
"comment": "可选;供需要指定 Place 的工作流使用。"
}
],
"scan": {
"included_names": [
"default.project.json",
"test.project.json",
"wally.toml",
"wally.lock",
"rokit.toml",
"aftman.toml",
"selene.toml",
"stylua.toml"
],
"included_extensions": [
".lua",
".luau",
".rbxlx",
".rbxmx"
],
"excluded_directories": [
"Packages",
"ServerPackages",
"ClientPackages",
"DevPackages",
".lune"
]
},
"gitignore_fragments": [
"fragments/roblox.gitignore"
],
"gitattributes_fragments": [
"fragments/roblox.gitattributes"
]
}

View File

@@ -0,0 +1,64 @@
---
description: Roblox/Luau 项目的架构、类型、安全、职责拆分与验证约束
alwaysApply: true
---
# Roblox / Luau 项目护栏
## 开始前
1. 先读取项目的 README、架构文档、Rojo 映射、工具配置、入口文件和相邻模块,确认真实目录与现有约定。
2. 修改模块位置、公共 API 或职责边界前,先检查调用方、依赖方与运行时归属。
3. 优先复用现有 Service、Controller、配置、Remote 定义、工具模块和验证命令,不建立功能重叠的平行体系。
## 架构与运行时边界
- `Shared` 只放客户端与服务端都可安全使用的类型、常量、配置、纯函数、序列化和网络契约。
- `Server` 持有持久化、经济、奖励、购买、交易、战斗结算、匹配、管理和反滥用逻辑。
- `Client` 只负责 UI、输入、相机、表现、特效、动画编排与必要的本地预测。
- 服务端专用资产和敏感逻辑不得放入客户端可读取的位置。
- Bootstrap 只负责组装生命周期,不承载大量业务逻辑。
- 共享抽象必须有真实复用价值,并保持依赖方向清晰;不要仅为减少表面重复而增加耦合。
## Server Authority、Remote 与安全
- 客户端只提交意图,服务端决定结果。货币、物品、伤害、任务、购买履约、交易和权限变更必须由服务端裁定。
- 集中维护 Remote 名称、方向、负载类型、验证器与限流策略;项目已有统一定义时不得用 `Instance.new` 临时创建 Remote。
- 每个客户端到服务端请求都验证参数形状、身份、权限、所有权、状态前置条件、距离或目标有效性、冷却/频率,以及需要时的幂等键。
- 不信任客户端提供的价格、数量、库存、伤害、奖励或目标标识;异常输入应明确拒绝,并按项目策略记录。
- 管理和调试命令必须由服务端实现、服务端授权并受环境限制;客户端 UI 可见性不是授权边界。
- 密钥、令牌、服务端授权表和敏感业务规则不得进入可复制位置或版本库。
## Luau 类型与 Roblox API
- 新模块在项目约定允许时使用 `--!strict`,为跨模块 API、Remote 负载和持久化结构提供明确类型。
- Roblox 运行时类型(如 `Instance`、`Vector3`、`CFrame`、`Color3`)使用 `typeof` 判断Lua 基础值使用 `type`。
- 共享时间戳使用 `workspace:GetServerTimeNow()`;不要用客户端时钟决定服务端结果。
- 鼠标与触摸输入需按目标平台同时考虑 `MouseButton1` 和 `Touch`。
- 重要生命周期不要依赖弱表自动回收;使用普通表和与销毁、离开或清理器绑定的显式清理。
- 关键配置缺失时明确报错或告警,不用魔法数字回退掩盖配置错误。
- 不虚构 Roblox API采用项目前先核对现有用法或官方接口。
## DataStore 与持久化
- 写入 DataStore 前将 Roblox 复杂值转换为纯表或基础类型;加载后校验、清洗并规范化。
- 持久化数据必须有 `SchemaVersion`、迁移策略、会话锁、自动保存、加载失败处理和安全停服保存方案。
- 开发、测试与生产数据按项目风险隔离。
- 奖励、购买回执和交易应支持重试且保持幂等,防止重复发放或重复扣除。
- 业务模块通过专用数据服务修改资料,不直接任意写入原始 profile 表。
## 脚本职责拆分
- 行数是维护信号,不是 Roblox 运行时限制;优先按所有权与生命周期拆分。
- 一个模块应能用一句话说明职责。Service 状态、网络处理、数据配置、UI、输入、持久化、领域算法和表现逻辑不应混在同一大文件中。
- 约 300500 行时评估职责是否仍单一;超过 500 行且新增明显职责时,优先抽取聚焦模块。
- 800 行以上的非简单修改默认先拆分,除非现有架构有明确理由;窄范围热修可以暂不重构,但需说明风险。
- 渐进式抽取并尽量保持公共 API拆分后检查全部调用方避免只为搬移行数而创建空洞抽象。
## 验证
1. 修改后检查受影响的客户端/服务端流程、数据形状、配置查找、Remote 校验与清理行为。
2. 优先运行仓库已有的固定验证入口;遵循项目声明的运行时和工具版本。
3. 若没有统一入口只使用仓库已具备的格式化、lint、类型分析和测试能力不得擅自安装依赖。
4. 验证范围优先覆盖改动文件及相关调用链,不默认格式化或重写整个项目。
5. 无法验证时明确说明缺失的命令、配置或环境条件,不得静默声称通过。

View File

@@ -0,0 +1,117 @@
---
name: building-3d-objects
description: 'Build or plan single Roblox 3D objects, props, and room-scale structures. Use when the user asks to model one Model-like entity such as furniture, vehicles, towers, doors, street props, interiors, or mentions CSG, Union, Subtract, Part placement, 3D modeling, 物件, 道具, 房间, 家具, or 建模. Route maps, arenas, towns, hubs, and multi-zone environments to building-maps.'
---
# Building 3D Objects in Roblox
用于把单体物件、道具和房间级结构收束成可实现设计,并产出 Roblox 实例、Luau 或结构计划。
## 执行方式
先根据用户目标和可用工具选择交付方式:
1. **直接构建**:用户要求修改场景且 Studio MCP 可用时,分段执行 Luau并在每段后读取现场状态。
2. **Luau 交付**:输出可执行、可重复运行的 Luau不得把 Studio MCP 当成运行前提。
3. **结构计划**:输出层级、几何清单、相对 CFrame、材质和验证步骤供用户或后续流程实现。
Studio MCP 是可选执行通道。不可用时继续产出 Luau 或结构计划,不把工具缺失当成设计阻塞。
若通过 Studio MCP 执行,注意 Luau 跨调用可能无状态:每次调用重新获取目标模型和基准实例,不依赖上一次调用中的变量或引用。
## Phase 0需求收束与 Design Brief
先判断请求范围。地图、竞技场、城镇、大厅、关卡或多区域环境,即使描述模糊,也转交 `building-maps`;不要把地图级问题继续收束成单体物件。
单体请求缺少结构、尺度、风格或活动方式时:
1. 一次只问一个问题,优先给 2-3 个具体选项。
2. 优先补齐会改变实现路径的信息;通常在 3-4 轮内结束,其余细节用专业判断补齐。
3. 用 Roblox 尺度锚点帮助用户量化:玩家约高 5 studs、门洞约宽 4/高 7 studs、室内层高约 10-14 studs、台面约高 3.5-4 studs、座面约高 1.5 studs。
4. 把抽象效果翻译成可实现形式:
- 高度有机造型 → 风格化块面、基础几何或低多边形近似;
- 自定义纹理 → 材质与颜色语言,必要时标注后续资产需求;
- 复杂文字 → 几何近似或后续 `SurfaceGui`
- 流体、烟雾、光晕 → 半透明体块、`Neon` 或后续特效层。
5. 对明显不可由 Parts/CSG 稳定实现的内容直接说明限制,不伪装为可行。
开始规划或构建前形成以下 Brief信息已明确时可直接填写不必额外追问
```text
--- Design Brief ---
Target model: [目标 Model 名称与交付范围]
Style: [风格]
Anchor: [基准 Part / Origin / PrimaryPart以及放置关系]
Numeric scale: [长 ×× 高,单位 studs列出关键高度或玩家比例]
Components: [主要部件]
Materials/Colors: [材质与颜色]
CSG: [yes/no若 yes列出切割或合并目标]
Behavior: [活动部件、碰撞或物理要求]
Special notes: [限制与验收重点]
--------------------
```
## Phase 1空间规划
确定:
- 根模型名与幂等创建/更新策略;
- 结构 anchor 和 `Model.PrimaryPart`(适用时);
- 命名尺寸表、统一网格步长和相对坐标关系;
- 放置基准:可配置 `FLOOR_Y`,或相对 `Origin` / `PrimaryPart`
- 主结构、次级结构、装饰细节的分段边界。
按需读取:
- 多阶段拼装或部件数大于 5`docs/spatial-patterns.md`
- 曲面、打孔、空腔或布尔运算:`docs/csg-patterns.md`
- Roblox 平台特殊行为:`docs/platform-rules.md`
## Phase 2分段实现
按“主结构 → 次级结构 → 装饰/细节”实现。无论是执行还是输出脚本,都遵守:
- 子部件相对 anchor 计算,不散落硬编码世界坐标;
- 尺寸使用命名变量,不在实例创建代码中堆叠魔法数字;
- 默认显式设置 `Anchored`、碰撞和阴影属性,除非设计需要物理模拟;
- 后续计算依赖现场几何时,先重新读取实际 `CFrame``Size`
- 直接执行时控制单段规模,失败后可安全重跑且不重复堆积实例。
CSG 必须使用 `pcall`、检查返回类型并保留原始几何直到成功。无论成功或失败,都清理 cutters、无效结果和临时容器中的中间对象失败时不得留下孤立临时实例。
## Phase 3验证与修复
1. 读取 `scripts/validate.luau`
2. 设置 `TARGET_MODEL_NAME`
3. 按场景选择 `FLOOR_MODE`,配置 `FLOOR_Y`,或使用相对 `Origin` / `PrimaryPart` 的基准与偏移。
4. 通过 Studio MCP、Studio 命令栏或项目既有执行方式运行。
5. 修复 `[ERROR]` 和影响验收的 `[WARN]`,然后重新验证。
有可视化通道时,再检查比例、轮廓、穿插和观看角度;没有可视化通道时,在交付中明确仍需人工确认的视觉项。
## 复盘与规则提升
出现失败、重复返工、验证告警、视觉回归或可复用成功做法时,做简短复盘:
- 失败模式:记录现象、可验证根因、本次修复和预防条件;
- 成功模式:记录有效做法、适用边界和支持它的验证证据;
- 证据优先使用实际执行结果、实例属性/层级、验证输出、截图和用户反馈;证据不足时只标为假设;
- 去掉对象名、一次性尺寸和审美偏好后仍成立,才视为通用候选;
- 偶发问题不得提升。候选至少应在两个独立构建中重复出现,或由确定性的引擎行为/验证复现直接证实;
- 即使达到证据门槛,修改本 Skill 或 docs 形成永久规则前也必须先取得用户确认。
## 反模式
- 需求范围或尺度未明确就直接开工;
- 失去坐标依据后凭对话记忆继续计算;
- 把复杂物件塞进一段不可维护的超长脚本;
- 默认布尔运算一定成功;
- 把固定世界 `Y=0` 当成所有场景的地面;
- 因 Studio MCP 不可用而拒绝输出 Luau 或结构计划。
## 补充文件
- `docs/spatial-patterns.md`:多部件相对定位与网格规则
- `docs/csg-patterns.md`:安全 CSG 与失败清理
- `docs/platform-rules.md`Roblox 平台行为
- `scripts/validate.luau`:模型客观验证

View File

@@ -0,0 +1,74 @@
# CSG Patterns
使用 `SubtractAsync``UnionAsync` 时遵守以下规则。
## 1. 保持布尔树浅层
1. 从最大的简单凸体开始。
2. 先完成必要的切孔和凹槽。
3. 再添加凸出细节;同材质部件若不需要单一碰撞体,可保留在 `Model` 中,不必全部 Union。
4. 避免对已经多次 Union 的结果继续深层 Subtract。
## 2. EPSILON
cutter 必须稍微穿过被切表面,避免共面导致薄皮或 Z-fighting。
```lua
local EPSILON = 0.05
local wall = Instance.new("Part")
wall.Size = Vector3.new(10, 10, 1)
local holeCutter = Instance.new("Part")
holeCutter.Size = Vector3.new(2, 2, 1 + EPSILON * 2)
holeCutter.CFrame = wall.CFrame
```
## 3. 安全执行与失败清理
原始 base 只在结果验证成功后销毁。cutters、无效结果和临时容器中的对象无论成功或失败都必须清理。
```lua
local function destroyIfPresent(instance)
if typeof(instance) == "Instance" and instance.Parent then
instance:Destroy()
end
end
local cutters = { holeCutter }
local success, result = pcall(function()
return wall:SubtractAsync(cutters)
end)
if not success or typeof(result) ~= "Instance" or not result:IsA("BasePart") then
destroyIfPresent(result)
for _, cutter in ipairs(cutters) do
destroyIfPresent(cutter)
end
warn("CSG operation failed; intermediate objects were removed")
return
end
result.CFrame = wall.CFrame
result.UsePartColor = true
result.Color = wall.Color
result.Material = wall.Material
result.Name = wall.Name
result.Anchored = wall.Anchored
result.CanCollide = wall.CanCollide
result.Parent = wall.Parent
wall:Destroy()
for _, cutter in ipairs(cutters) do
destroyIfPresent(cutter)
end
```
若 CSG 在专用临时 `Folder` / `Model` 内执行,失败分支还应销毁该容器内本次调用创建的全部实例;不要删除调用前已存在的源几何。
## 4. 验证
- 结果必须是有效 `BasePart` 并已挂到预期父级。
- 重新应用颜色、材质、碰撞、锚定和命名。
- 检查 cutter 和临时容器中没有遗留对象。
- 对可见孔洞检查是否贯穿、是否存在薄皮或异常碰撞。

View File

@@ -0,0 +1,62 @@
# Roblox Platform Rules
## 1. Cylinder Orientation
Roblox 的 `Cylinder` 默认沿 X 轴延伸。要让圆柱竖直,交换长度/直径对应的尺寸并绕 Z 轴旋转 90°。
```lua
local pillar = Instance.new("Part")
pillar.Shape = Enum.PartType.Cylinder
pillar.Size = Vector3.new(10, 2, 2) -- Length, Diameter, Diameter
pillar.CFrame = CFrame.new(0, 5, 0) * CFrame.Angles(0, 0, math.pi / 2)
```
## 2. Neon 与实际光照
`Neon` 看起来会发光,但不会自动照亮周围物体。需要真实照明时,给发光 Part 添加 `PointLight``SpotLight``SurfaceLight`
```lua
local lamp = Instance.new("Part")
lamp.Material = Enum.Material.Neon
lamp.Color = Color3.fromRGB(255, 255, 200)
local light = Instance.new("PointLight")
light.Color = lamp.Color
light.Range = 15
light.Brightness = 2
light.Parent = lamp
```
## 3. BasePart 基础属性
除非需要物理交互,否则显式设置:
- `Anchored = true`
- `CanCollide = true`;小型装饰或触发器按需设为 `false`
- `CastShadow = true`;不可见触发器或纯发光装饰按需设为 `false`
## 4. CSG 平台约束
- 复杂 CSG 在远离世界原点处更容易受浮点精度影响。可在世界原点附近的临时容器中处理,再用 `PivotTo()` 放置最终模型。
- 布尔运算只对支持的 `Part` / `PartOperation` 使用;不要对 `MeshPart``Terrain` 调用。
- 不要求精确物理外形的装饰 Union可使用较简单的 `CollisionFidelity` 以降低成本。
- 临时容器、cutters 和无效结果都属于中间对象;成功或失败后必须清理。
## 5. WedgePart Orientation
`WedgePart` 的零高度尖端默认朝 +Z。不能只根据 `Size` 推断方向,必须显式设置旋转。
```lua
local tip = Instance.new("WedgePart")
tip.Size = Vector3.new(0.3, 1.0, 0.3)
tip.CFrame = baseCFrame
* CFrame.new(0, halfBladeHeight + 0.5, 0)
* CFrame.Angles(-math.pi / 2, 0, 0)
```
常用方向:
- +Y`CFrame.Angles(-math.pi / 2, 0, 0)`
- -Y`CFrame.Angles(math.pi / 2, 0, 0)`
- +Z无需旋转
- -Z`CFrame.Angles(math.pi, 0, 0)`

View File

@@ -0,0 +1,62 @@
# Spatial Patterns
多阶段构建或部件数大于 5 时,用以下模式保持尺度和坐标一致。
## 1. Geometric Manifest
先把尺寸声明为命名变量,不在实例创建代码中使用散落的魔法数字。
```lua
local DeskDef = {
Width = 6.0,
Depth = 3.0,
Height = 2.8,
TopThickness = 0.2,
LegSize = 0.3,
LegInset = 0.1,
}
```
尺寸应与 Design Brief 的 numeric scale 一致。需要对齐的尺寸使用统一网格,例如 0.125、0.25 或 0.5 studs避免累积出可见缝隙。
## 2. Anchor Pattern
选择最大的中心件、底座、`Origin``Model.PrimaryPart` 作为 anchor。其他部件只保存相对偏移。
放置基准可以是配置的世界地面,也可以来自场景实例:
```lua
local FLOOR_Y = 0 -- 按当前场景配置,不是全局假设
local origin = workspace:FindFirstChild("Origin")
local baseCFrame = if origin and origin:IsA("BasePart")
then origin.CFrame
else CFrame.new(0, FLOOR_Y, 0)
local top = Instance.new("Part")
top.Name = "Top"
top.Size = Vector3.new(DeskDef.Width, DeskDef.TopThickness, DeskDef.Depth)
top.CFrame = baseCFrame * CFrame.new(
0,
DeskDef.Height - DeskDef.TopThickness / 2,
0
)
local legHeight = DeskDef.Height - DeskDef.TopThickness
local leg = Instance.new("Part")
leg.Name = "Leg"
leg.Size = Vector3.new(DeskDef.LegSize, legHeight, DeskDef.LegSize)
local offsetX = DeskDef.Width / 2 - DeskDef.LegSize / 2 - DeskDef.LegInset
local offsetZ = DeskDef.Depth / 2 - DeskDef.LegSize / 2 - DeskDef.LegInset
local offsetY = -DeskDef.TopThickness / 2 - legHeight / 2
leg.CFrame = top.CFrame * CFrame.new(offsetX, offsetY, offsetZ)
```
若已有目标模型及 `PrimaryPart`,优先以 `model.PrimaryPart.CFrame` 作为 `baseCFrame`。对旋转后的 anchor始终使用 CFrame 乘法保持局部方向,不把偏移退化成世界坐标加法。
## 3. 分段与重入
- 每段先按稳定名称获取或创建实例。
- 更新已有实例的属性,不盲目创建同名副本。
- 后续计算依赖当前几何时,重新读取 anchor 的真实 `CFrame``Size`
- 脚本中集中定义目标模型、anchor 名、尺寸和网格参数,方便用户调整。

View File

@@ -0,0 +1,139 @@
-- Roblox object validation.
-- Run through an available Studio execution channel after configuring this block.
local TARGET_MODEL_NAME = "YourModelNameHere"
local FLOOR_MODE = "AUTO" -- "FLOOR_Y", "ORIGIN", "PRIMARY_PART", "AUTO", or "NONE"
local FLOOR_Y = 0
local REFERENCE_Y_OFFSET = 0
local FLOOR_TOLERANCE = 0.1
local target = workspace:FindFirstChild(TARGET_MODEL_NAME)
if not target then
print("[ERROR] Target '" .. TARGET_MODEL_NAME .. "' not found in workspace.")
return
end
local errors = 0
local warnings = 0
local parts = {}
if target:IsA("BasePart") then
table.insert(parts, target)
else
for _, descendant in ipairs(target:GetDescendants()) do
if descendant:IsA("BasePart") then
table.insert(parts, descendant)
end
end
end
local function referenceY(instance)
if instance:IsA("BasePart") then
return instance.Position.Y
end
if instance:IsA("Attachment") then
return instance.WorldPosition.Y
end
return nil
end
local function findOrigin()
local origin = target:FindFirstChild("Origin", true)
if not origin then
origin = workspace:FindFirstChild("Origin")
end
if origin and referenceY(origin) ~= nil then
return origin
end
return nil
end
local function findPrimaryPart()
if target:IsA("Model") then
return target.PrimaryPart
end
return nil
end
local function resolveFloor()
if FLOOR_MODE == "NONE" then
return nil, nil, nil
end
if FLOOR_MODE == "FLOOR_Y" then
return FLOOR_Y, nil, nil
end
local reference = nil
if FLOOR_MODE == "ORIGIN" then
reference = findOrigin()
elseif FLOOR_MODE == "PRIMARY_PART" then
reference = findPrimaryPart()
elseif FLOOR_MODE == "AUTO" then
reference = findOrigin() or findPrimaryPart()
if not reference then
return FLOOR_Y, nil, nil
end
else
return nil, nil, "Unknown FLOOR_MODE '" .. FLOOR_MODE .. "'."
end
if not reference then
return nil, nil, "Floor reference for mode '" .. FLOOR_MODE .. "' was not found."
end
return referenceY(reference) + REFERENCE_Y_OFFSET, reference, nil
end
local floorY, floorReference, floorError = resolveFloor()
if floorError then
print("[ERROR] " .. floorError)
errors += 1
end
local function lowestWorldY(part)
local cframe = part.CFrame
local size = part.Size
local halfExtentY = math.abs(cframe.RightVector.Y) * size.X / 2
+ math.abs(cframe.UpVector.Y) * size.Y / 2
+ math.abs(cframe.LookVector.Y) * size.Z / 2
return cframe.Position.Y - halfExtentY
end
print("--- Validating Target: " .. TARGET_MODEL_NAME .. " ---")
for _, part in ipairs(parts) do
if not part.Anchored then
print("[ERROR] Part '" .. part:GetFullName() .. "' is not Anchored.")
errors += 1
end
if floorY ~= nil and part ~= floorReference then
local lowestPoint = lowestWorldY(part)
if lowestPoint < floorY - FLOOR_TOLERANCE then
print(string.format(
"[WARN] Part '%s' is below the configured floor (lowest %.2f, floor %.2f).",
part:GetFullName(),
lowestPoint,
floorY
))
warnings += 1
end
end
if part.Color == Color3.new(163 / 255, 162 / 255, 165 / 255)
and part.Material == Enum.Material.Plastic then
print("[WARN] Part '" .. part:GetFullName() .. "' uses default Plastic/Grey.")
warnings += 1
end
end
if #parts == 0 then
print("[ERROR] Target contains no BaseParts.")
errors += 1
end
print("--- Validation Complete ---")
print(string.format("Parts: %d | Errors: %d | Warnings: %d", #parts, errors, warnings))
if errors > 0 then
print("ACTION REQUIRED: Fix the errors above and run validation again.")
end

View File

@@ -0,0 +1,90 @@
---
name: building-maps
description: 'Build or plan Roblox maps, arenas, towns, hubs, levels, and other multi-zone environments. Use when the user asks for a map, combat scene, lobby, village, open area, level layout, or any space spanning multiple zones or regions, including 地图, 竞技场, 战斗场景, 大厅, or 关卡. Route a single object, prop, or room-scale structure to building-3d-objects.'
---
# Building Maps in Roblox
用于多区域地图与环境布局,重点是尺度、玩法拓扑、区域关系、路径、地标和环境氛围。
## 执行方式
根据用户目标和可用工具选择:
1. **直接构建**:用户要求修改场景且 Studio MCP 可用时,按阶段执行并读取现场状态。
2. **Luau 交付**:输出可重复运行的分阶段 Luau不依赖 Studio MCP。
3. **结构计划**:输出层级、区域坐标、路径宽度、地标、生成顺序和验证步骤。
Studio MCP 只是可选执行通道。不可用时继续交付 Luau 或结构计划,不得把工具缺失当成地图设计阻塞。
若通过 Studio MCP 执行Luau 跨调用可能无状态:每次调用重新获取 `MapRoot``Origin`,关键阶段后重新读取现场,不依赖上一次调用中的变量。
## Phase 1布局澄清
开工前确认:
1. 总体 numeric scale 与玩家尺度;
2. 玩法拓扑、主路径和支路;
3. 区域数量、边界与地标;
4. 放置基准:可配置 `FLOOR_Y`,或相对 `Origin` / `PrimaryPart`
任一关键项不清楚时,一次只问一个会改变布局的问题,并优先给出具体选项。信息足够后形成简短 Map Brief至少包含地图根名称、anchor、长宽高或半径studs、区域列表、主路径宽度、出生点和验收重点。
单个物件或房间级复杂结构转交 `building-3d-objects`
## Phase 2-6分阶段落地
读取并遵循 `docs/map-build-phases.md`。无论直接执行还是输出脚本,都满足:
- 所有 zone、地标、出生点和环境内容归入地图根层级
- 区域相对 `Origin.CFrame``Model.PrimaryPart.CFrame` 定位;
- 命名尺寸与坐标集中声明,不把地图散落成硬编码世界坐标;
- 分阶段脚本可安全重跑,不创建同名副本;
- 后续计算依赖已有几何时,先读取实际状态;
- 使用 CSG 时遵循 `building-3d-objects/docs/csg-patterns.md`;失败也要清理 cutters、无效结果和临时容器内的中间对象。
## 验证
读取 `docs/map-verification.md`
1. 设置 `MAP_ROOT_NAME`
2. 选择 `FLOOR_MODE`,配置 `FLOOR_Y`,或使用相对 `Origin` / `PrimaryPart` 的基准与偏移。
3. 通过可用的 Studio 执行通道运行脚本;不能运行时,连同人工验证清单一起交付。
4. 修复错误和影响验收的告警,然后重新验证。
有可视化通道时检查主路径、区域识别、出生点安全和地标可读性;没有时明确标出待人工确认项。
## 推荐层级
```text
workspace/
MapName/
Origin
Terrain/
Zone_A/
Zone_B/
Landmarks/
Lighting/
Spawns/
```
## 复盘与规则提升
出现失败、重复返工、验证告警、视觉回归或可复用成功做法时,做简短复盘:
- 失败模式:记录现象、可验证根因、本次修复和预防条件;
- 成功模式:记录有效布局/构建做法、适用边界和验证证据;
- 证据优先使用执行结果、层级与实例属性、验证输出、截图、路径测试和用户反馈;不足时只标为假设;
- 去掉地图名、一次性尺寸和单次审美偏好后仍成立,才视为通用候选;
- 偶发问题不得提升。候选至少应在两个独立构建中重复出现,或由确定性的引擎行为/验证复现直接证实;
- 即使达到证据门槛,修改本 Skill 或 docs 形成永久规则前也必须先取得用户确认。
## 反模式
- 地图拓扑不清晰就直接填充细节;
- 凭印象继续推坐标而不读取现场;
- 所有 zone 使用相同材质、轮廓和地标语言;
- 主路过窄,出生点没有安全落脚面;
- 内容散落在地图根层级之外;
- 把固定世界 `Y=0` 当成所有地图的地面;
- 因 Studio MCP 不可用而拒绝输出 Luau 或结构计划。

View File

@@ -0,0 +1,61 @@
# 地图构建分阶段清单
## Phase 2地面、基准与边界
先创建或规划:
- `MapRoot`
- `Origin`,或设置 `Model.PrimaryPart`
- 主地面/地形壳
- 外圈边界
- 顶层容器:`Terrain``Landmarks``Spawns` 和各 Zone
直接执行时,每段建议控制在 10-20 个实例,并按稳定名称更新已有内容,保证可重跑。
## Phase 3区域壳体
相对 `Origin.CFrame``MapRoot.PrimaryPart.CFrame` 定位:
- zone 地板
- 墙体与分隔
- 主路径、支路
- 桥、坡道、悬崖等结构骨架
用命名参数声明地图宽度、区域中心、路径宽度和高度差。放置地面可使用显式 `FLOOR_Y`,或以 anchor 的局部 Y=0 平面为参考;不要默认世界 `Y=0`
如果后续计算依赖当前几何,先重新读取现状再继续。
## Phase 4地标
创建能帮助玩家定向的锚点,例如:
-
- 喷泉
- 大门
- 雕像
- 大树
当地标变成单个复杂物件问题时,读取 `../../building-3d-objects/SKILL.md` 及其相关 docs再把完成物相对地图 anchor 放置。
## Phase 5区域填充
按玩法需要加入:
- 掩体
- 家具
- 植被
- 小型道具
- 战术细节
填充服务于路径、节奏和区域识别,不让视觉噪声盖过主结构。复用模板时仍要保留 zone 差异。
## Phase 6环境、光照与出生点
至少处理:
- `Lighting`
- `Atmosphere`
- 局部灯光
- `SpawnLocation`
出生点必须有安全落脚面并朝向可理解的首要目标。需要氛围变化时同步考虑全局光照、雾和颜色,不只堆叠局部灯光。

View File

@@ -0,0 +1,135 @@
# 地图验证清单
## 客观验证脚本
配置地图根名称和地面参考后,通过可用的 Studio 执行通道运行。`AUTO` 会依次尝试 `Origin``PrimaryPart`,最后使用 `FLOOR_Y`
```lua
local MAP_ROOT_NAME = "YourMapName"
local FLOOR_MODE = "AUTO" -- "FLOOR_Y", "ORIGIN", "PRIMARY_PART", "AUTO", or "NONE"
local FLOOR_Y = 0
local REFERENCE_Y_OFFSET = 0
local FLOOR_TOLERANCE = 0.5
local root = workspace:FindFirstChild(MAP_ROOT_NAME)
if not root then
print("[ERROR] " .. MAP_ROOT_NAME .. " not found")
return
end
local errors, warnings, partCount = 0, 0, 0
local function referenceY(instance)
if instance:IsA("BasePart") then
return instance.Position.Y
end
if instance:IsA("Attachment") then
return instance.WorldPosition.Y
end
return nil
end
local function findOrigin()
local origin = root:FindFirstChild("Origin", true)
if origin and referenceY(origin) ~= nil then
return origin
end
return nil
end
local function findPrimaryPart()
if root:IsA("Model") then
return root.PrimaryPart
end
return nil
end
local function resolveFloor()
if FLOOR_MODE == "NONE" then
return nil, nil, nil
elseif FLOOR_MODE == "FLOOR_Y" then
return FLOOR_Y, nil, nil
end
local reference = nil
if FLOOR_MODE == "ORIGIN" then
reference = findOrigin()
elseif FLOOR_MODE == "PRIMARY_PART" then
reference = findPrimaryPart()
elseif FLOOR_MODE == "AUTO" then
reference = findOrigin() or findPrimaryPart()
if not reference then
return FLOOR_Y, nil, nil
end
else
return nil, nil, "Unknown FLOOR_MODE '" .. FLOOR_MODE .. "'."
end
if not reference then
return nil, nil, "Floor reference for mode '" .. FLOOR_MODE .. "' was not found."
end
return referenceY(reference) + REFERENCE_Y_OFFSET, reference, nil
end
local function lowestWorldY(part)
local cframe = part.CFrame
local size = part.Size
local halfExtentY = math.abs(cframe.RightVector.Y) * size.X / 2
+ math.abs(cframe.UpVector.Y) * size.Y / 2
+ math.abs(cframe.LookVector.Y) * size.Z / 2
return cframe.Position.Y - halfExtentY
end
local floorY, floorReference, floorError = resolveFloor()
if floorError then
print("[ERROR] " .. floorError)
errors += 1
end
for _, descendant in ipairs(root:GetDescendants()) do
if descendant:IsA("BasePart") then
partCount += 1
if not descendant.Anchored then
print("[ERROR] " .. descendant:GetFullName() .. " is not Anchored")
errors += 1
end
if floorY ~= nil and descendant ~= floorReference then
local lowestPoint = lowestWorldY(descendant)
if lowestPoint < floorY - FLOOR_TOLERANCE then
print(string.format(
"[WARN] %s is below the configured floor (lowest %.2f, floor %.2f)",
descendant:GetFullName(),
lowestPoint,
floorY
))
warnings += 1
end
end
if descendant.Color == Color3.new(163 / 255, 162 / 255, 165 / 255)
and descendant.Material == Enum.Material.Plastic then
print("[WARN] " .. descendant:GetFullName() .. " uses default color")
warnings += 1
end
end
end
if partCount == 0 then
print("[ERROR] Map root contains no BaseParts")
errors += 1
end
print(string.format("Parts: %d | Errors: %d | Warnings: %d", partCount, errors, warnings))
```
## 额外人工检查
1. 主路径是否连续、足够宽且没有不可见阻挡。
2. zone 边界和视觉语言是否清楚。
3. 出生点是否安全、是否落在实际表面并朝向首要目标。
4. 地标是否能帮助玩家定向。
5. 所有地图内容是否位于地图根层级下。
6. 区域间高度差、坡度和跳跃距离是否符合目标移动能力。
7. 视觉检查无法自动执行时,是否已明确标记待人工确认项。

View File

@@ -0,0 +1,239 @@
---
name: roblox-animation-producer
description: >-
设计并集成 Roblox R15 骨骼动画,产出 Motion Spec、可复现的 KeyframeSequence
构建源码、Studio 手工发布步骤和项目接入改动。用于待机、走跑、攻击、技能、Emote、
宠物动作或批量动画;先探测 Rojo 映射、Rig 与现有动画模块,不假定管理器或注册表存在。
---
# Roblox Animation Producer
以“项目探测 → 动作设计 → 关键帧源码 → Studio 预览/发布 → 项目集成”产出可复现动画。AnimationId 的正式发布是 Studio 中的人工步骤Studio MCP 仅在可用时辅助验证。
## 核心规则
1. **先探测后集成**不得假定项目存在特定动画管理器、配置表、Controller 或命名约定。
2. **源码为主**Motion Spec、构建器、运行时调用和可配置 ID 必须落在 Rojo/项目源码或项目已采用的资源格式中。
3. **设计先行**:每个动画先写 Motion Spec再生成 Pose。
4. **节奏分段**:至少定义预备、主体和 Recovery/保持;不能用一个 Pose 代表完整动作。
5. **曲线明确**:使用 `Enum.PoseEasingStyle``Enum.PoseEasingDirection`,不与 TweenService 的枚举混用。
6. **层级来自 Rig**:标准 R15 可用标准树;自定义/宠物 Rig 必须检查实际 Motor6D不为不存在的部件盲目生成 Pose。
7. **重心与惯性**:全身动作包含躯干/重心变化;攻击等关键动作使用 anticipation、overshoot、follow-through 或 hit pause。
8. **移动中上半身动作**:不应覆盖腿部 locomotion按项目混合策略省略腿部 Pose并验证 Root/LowerTorso 权重或变换不会造成滑步。
9. **Recovery 统一规则**
- 非循环 OneShot 在稳定的 Recovery/follow-through 姿态结束,由播放层 FadeOut 融合回底层动画;默认不追加 identity/Idle 关键帧。
- Looped 动画末帧必须与首帧闭环。
- 只有明确的玩法/演出契约要求动画自身归位,或现有播放层没有可用 FadeOut 且用户接受该设计时,才添加归位帧,并在 Motion Spec 标明原因。
10. **ID 不散落**:正式 AnimationId 按目标项目真实约定放入配置、Attribute 或资产数据;没有注册表时不伪造一个同名注册表。
11. **发布必须人工**:正式 AnimationId 的创建、Creator/Group 归属、权限和发布由用户在 Studio 完成。
12. **MCP 可选**工具不可用时仍要完成源码、静态检查、Studio 手工预览与发布说明。
## 阶段 0探测项目
先记录以下事实与路径:
1. 读取 `default.project.json` 等 Rojo project 文件,定位共享模块、客户端代码、配置和开发工具目录。
2. 搜索 `Animation``Animator``LoadAnimation``AnimationId``KeyframeSequence``GetMarkerReachedSignal``Play``Stop``fadeTime`
3. 阅读候选播放模块的真实签名、轨道缓存、优先级、预载、停止和模型销毁处理。
4. 查找 ID 存储方式:配置表、模型 Attribute、常量、数据资产或 CollectionService tag。
5. 从真实调用方确认 key 命名、播放参数、marker 约定和服务端/客户端边界。
6. 确认目标 Rig标准 R15、缩放 R15、R15-like 宠物或自定义 Motor6D 树。
7. 查找项目是否已有动画构建脚本、`.rbxm/.rbxmx`、Moon Animator/Animation Editor 工作流或测试 Rig。
根据结果选择:
- **已有播放层和 ID 存储**:复用真实接口与格式。
- **只有直接 Animator 调用**:沿用项目局部模式,或在重复需求明确时提出最小适配层。
- **没有注册表**:不要为了匹配示例而创建固定名称;先选择现有配置/Attribute 约定。
- **Rig 只存在 Studio**源码保留构建器Studio 中人工核对 Motor6D 和预览。
详见 [docs/project-integration.md](docs/project-integration.md)。
## 阶段 1Motion Spec
至少确认:
| 要素 | 示例 |
|---|---|
| 动画类型 | Idle / Walk / Attack / Skill / Emote |
| Rig | 标准 R15 / 自定义 R15-like |
| 风格 | 卡通、写实、Q 版、沉重 |
| 时长 | 0.6s / 2.0s |
| 播放模式 | OneShot / Looped |
| 优先级 | Idle / Movement / Action |
| 使用场景 | 玩家普攻、宠物待机、Boss 技能 |
| 混合约束 | 移动中播放、可打断、Combo |
| 事件时刻 | Hit、Footstep、Release |
把动作拆成 35 段:
```text
动画重击0.60s, OneShot, Action
0.000.18 Anticipation后仰、手臂蓄力
0.180.24 Strike快速前压、挥臂 overshoot
0.240.30 Hit pause / Follow-through
0.300.60 Recovery缓冲到稳定残余姿态
结束策略:由播放层 FadeOut 回到底层动画,不追加 Idle 帧
```
Motion Spec 必须写明:
- 每段骨骼目标和 `PoseEasingStyle/Direction`
- 重心、主导肢体和次级跟随;
- hit/footstep/release marker 时刻;
- 可打断窗口、循环闭环或 OneShot 结束策略;
- 是否只驱动上半身。
## 阶段 2KeyframeSequence 构建源码
标准 R15 层级与轴向见 [docs/r15-rig-reference.md](docs/r15-rig-reference.md),动作模式见 [docs/animation-patterns.md](docs/animation-patterns.md)。
构建器要求:
- 每帧时间对齐项目帧率网格;
- Pose 名与 Motor6D 的 Part1 名匹配;
- 子 Pose 按实际父子层级嵌套;
- 显式设置 easing
- Weight 允许为 0不能被默认值覆盖
- 添加 marker 时使用 Keyframe 名称或项目现有 marker 约定;
- 构建结果返回 `KeyframeSequence`,由 Studio 引导脚本决定暂存位置。
示例骨架:
```lua
local function makePose(data)
local pose = Instance.new("Pose")
pose.Name = data.name
pose.CFrame = data.cframe or CFrame.new()
pose.Weight = if data.weight == nil then 1 else data.weight
pose.EasingStyle = data.easingStyle or Enum.PoseEasingStyle.Cubic
pose.EasingDirection = data.easingDirection or Enum.PoseEasingDirection.InOut
for _, child in data.children or {} do
pose:AddSubPose(makePose(child))
end
return pose
end
local function buildSequence(spec)
local sequence = Instance.new("KeyframeSequence")
sequence.Name = spec.name
sequence.Loop = spec.looped
sequence.Priority = spec.priority
for _, frame in spec.keyframes do
local keyframe = Instance.new("Keyframe")
keyframe.Time = math.round(frame.time * spec.fps) / spec.fps
keyframe.Name = frame.name or `KF_{frame.time}`
keyframe:AddPose(makePose(frame.rootPose))
sequence:AddKeyframe(keyframe)
end
return sequence
end
```
根据项目 Luau 版本和 lint 规则调整语法,不机械复制示例。
### Easing 速查
`Enum.PoseEasingStyle` 常用合法值:`Linear``Constant``Elastic``Cubic``Bounce`
| 阶段 | Style | Direction |
|---|---|---|
| 蓄力 | Cubic | InOut |
| 快速攻击 | Cubic | Out |
| 硬切/停帧 | Constant | In |
| Overshoot 回弹 | Bounce/Elastic | Out |
| Recovery | Cubic | InOut |
| 循环呼吸 | Cubic | InOut |
## 阶段 3无 MCP 的 Studio 预览与发布
以下是主路径:
1. 按项目文档启动其声明版本的 Rojo并在 Studio 打开正确 Place。
2. 连接 project确认动画构建 ModuleScript 已同步到预期位置。
3. 在 Command Bar 或项目已有开发工具中 `require` 真实模块并调用构建函数,把结果暂存到 `ServerStorage` 或项目指定开发容器。
4. 选择与目标骨架一致的测试 Rig在 Explorer 检查 Motor6D 层级和部件命名。
5. 使用 Animation Editor 导入/打开生成的 KeyframeSequence逐个检查关键姿态、曲线、marker 和循环。
6. 在 Animation Editor 中执行 **Publish to Roblox**(界面文字可能随 Studio 版本变化)。
7. 选择正确 Creator/Group 和目标 Experience填写名称后发布复制正式数字 ID。
8.`rbxassetid://<id>` 回填到阶段 0 找到的真实配置、Attribute 或资产数据。
9. 通过游戏真实入口测试 FadeIn/FadeOut、优先级、打断、移动叠加和 marker。
正式发布不能由源码、Rojo 或临时 KeyframeSequence ID 替代。发布权限或归属不明确时停止在“待用户发布并回填 ID”状态不伪造 ID。
### Studio MCP 可用时
先发现当前服务器实际暴露的能力,再辅助检查 Rig、构建临时序列、摆放对比 Dummy 或截图。不得假定固定工具名MCP 不可用时回到上述步骤。所有持久结果仍需回写项目源码,正式发布仍由用户在 Studio 完成。
## 阶段 4项目集成
1. 按阶段 0 的证据选择 ID 存储位置。
2. 使用项目真实播放 API若没有封装则使用现有 Animator 调用模式。
3. 设置 loop、priority、speed、fade、weight 和可打断策略。
4. marker 只驱动视觉/音频提示时可在客户端处理;伤害等玩法结果仍由服务端权威判定。
5. 对角色重生、模型销毁和重复播放清理 Track/Connection。
6. 把任何示例注册表或管理器视为示意,除非当前源码证明确实存在。
## 阶段 5验证
### 静态/结构
- [ ] 构建源码位于正确 Rojo 映射。
- [ ] Keyframe 时间单调、首帧为 0、末帧符合设计时长。
- [ ] Pose 层级匹配目标 Motor6D。
- [ ] easing 不是无意的全 Linear。
- [ ] OneShot 的末帧是 Recovery不是未经说明的强制 Idle。
- [ ] Looped 首尾 Pose、Weight 和 marker 边界闭环。
- [ ] 上半身动作未无意覆盖腿部 locomotion。
### Studio/运行时
- [ ] 关键 Pose 无扭曲、穿模或反向插值。
- [ ] 攻击有 anticipation、impact/follow-through 和可读 Recovery。
- [ ] FadeOut 后自然回到底层动画,无突跳或拖沓。
- [ ] 优先级、速度、循环、打断和重复播放符合玩法。
- [ ] marker 时刻与实际画面一致。
- [ ] 正式 ID 的 Creator/Group、Experience 权限和发布状态正确。
- [ ] 角色销毁或重生后无 Track/Connection 残留。
失败时回到 Motion Spec 或关键帧源码修正,不在 Studio 临时拖帧后忘记同步源数据。
## 批量模式
批量动画使用结构化 spec但每个动画独立验收
```lua
return {
{
name = "PetIdle",
duration = 2.0,
looped = true,
priority = Enum.AnimationPriority.Idle,
type = "Idle",
},
{
name = "PetAttack",
duration = 0.7,
looped = false,
priority = Enum.AnimationPriority.Action,
type = "Attack",
endStrategy = "RecoveryThenFadeOut",
},
}
```
不要因批量生成而复用完全相同的 Motion SpecRig 比例、个性和玩法时刻仍需逐项调整。
## 交付清单
- 项目探测结果与目标 Rig 证据;
- Motion Spec 和结束/循环策略;
- KeyframeSequence 构建源码;
- Studio 手工预览与发布步骤;
- 待用户回填的正式 AnimationId如尚未发布
- 真实项目集成改动;
- 自动检查、Studio 验证结果与剩余技术风险。

View File

@@ -0,0 +1,210 @@
# Animation Patterns
以下是 Motion Spec 与关键帧起点,不是可直接套用所有 Rig 的最终动画。角度必须结合目标 Motor6D、比例和摄像机实测。
## 统一结束规则
- **Looped**:末帧与首帧的 Pose、Weight 和可见状态闭环。
- **OneShot**:默认在 Recovery/follow-through 姿态结束,由播放层 FadeOut 回到底层动画。
- **不要**在 OneShot 示例后偷偷追加 identity/Idle 帧。
- 只有 Motion Spec 明确说明“资产内归位”的理由时,才添加归位帧。
## 1. Idle呼吸
设计:
- 23 秒循环;
- UpperTorso 呼吸、Head 延迟、手臂微摆;
- 运动幅度小,首尾完全一致;
- `Cubic/InOut` 近似平滑呼吸。
```text
0.00 基准姿态
0.50 UpperTorso 后仰 2°Head 微抬 1°
1.00 呼吸峰值,手臂向外 1°
1.50 UpperTorso 前倾 1°Head 延迟回落
2.00 = 0.00
```
避免每个骨骼同相位移动Head/Hands 可比躯干晚一个短关键帧,形成跟随感。
## 2. Walk交替步伐
设计:
- 约 0.81.1 秒循环两步;
- 腿与对侧手臂反向摆动;
- 接触、下沉、经过、抬升四个节奏点;
- Root/LowerTorso 的 bounce 很小,避免角色视觉漂浮。
```text
0.00 右脚接触前方,左脚后蹬;左臂前摆
0.25 下沉/经过,双腿接近中立
0.50 左脚接触前方,右脚后蹬;右臂前摆
0.75 下沉/经过
1.00 = 0.00
```
标准 R15 起始幅度可从:
```text
UpperLeg X: ±20°30°
LowerLeg X: 5°20°
UpperArm X: ±12°22°
UpperTorso roll: ±2°
Root vertical offset: 00.05 studs
```
项目 locomotion 可能由 Roblox 默认 Animate 或自定义状态机控制;先确认替换点和优先级。
## 3. Run前倾与更强步幅
相对 Walk
- 周期约 0.50.7 秒;
- UpperTorso 前倾约 8°12°
- 大腿摆幅约 ±30°45°
- 手臂摆幅约 ±25°40°
- bounce 更快,但位移仍不能替代角色真实移动。
Loop 仍必须首尾闭环。不要给 Root 添加累积前移,否则会与角色控制器争夺位移。
## 4. 近战攻击Anticipation → Strike → Recovery
示例0.60 秒 OneShot。
```text
KF0 0.00 当前动作的中性起点
KF1 0.18 Anticipation [Cubic/InOut]
UpperTorso: 后仰 10°向攻击侧旋转
LowerTorso: 后移重心 4°
RightUpperArm: 向后蓄力(标准 R15 X 负向起步)
RightLowerArm: 弯曲
Head: 保持看向目标
KF2 0.24 Strike [Cubic/Out]
UpperTorso: 快速前倾并反向旋转
LowerTorso: 前压
RightUpperArm: 沿弧线前挥并略 overshoot
RightLowerArm: 伸展但避免锁死
KF3 0.28 Hit pause / Follow-through [Constant 或紧邻关键帧]
保持主要轮廓,次级骨骼继续少量跟随
KF4 0.60 Recovery [Cubic/InOut]
UpperTorso: 回到轻微前倾/残余旋转
RightUpperArm: 回收至稳定残余角度
LowerTorso: 恢复大部分重心
动画在此结束,由 FadeOut 融合;无额外 Idle 帧
```
大跨度挥臂应加中间引导帧,避免最短旋转路径造成反向扭曲。
### 移动中上半身攻击
- 省略腿部 Pose。
- Root/LowerTorso 只保留项目确实需要的混合信息;通常不驱动位移。
- 若必须通过层级保留 LowerTorso按项目验证结果将其 Weight 设 0 或使用不会覆盖 locomotion 的最小变换。
- UpperTorso 仍可参与动作,但要实测 Pose 权重如何沿层级影响子骨骼。
不要仅凭 `Weight = 0` 假定所有 Rig/播放层都获得理想混合效果。
## 5. 远程攻击 / 施法
节奏:
```text
Anticipation聚集、瞄准
Release短促伸展
Hold极短保持供 VFX/marker 对齐
Recovery收回到稳定残余姿态
```
建议 marker
- `ChargeStart`
- `Release`
- `RecoveryStart`
marker 适合触发客户端表现;玩法伤害仍由服务端校验。
## 6. Jump / Land
跳跃动画要与角色物理状态配合:
```text
Takeoff下压 → 伸展
Air躯干与四肢形成清晰空中轮廓
Land接触 → 下压 → Recovery
```
如果 Takeoff、Air、Land 是分离资产:
- Air 可 Loop但首尾要稳定
- Land 是 OneShot以 Recovery 结束并 FadeOut 到 locomotion
- 不在动画中写入累积世界位移。
## 7. Pet Idle
宠物待机可比人形更夸张:
```text
0.00 基准
0.60 身体左倾,头部反向补偿
1.20 呼吸抬升
1.80 身体右倾,头部延迟
2.50 = 0.00
```
只为实际存在的部件创建 Pose。四足 Rig 不应强套人形 Arms/Legs 命名。
## 8. Pet Happy / Emote
Looped Happy
```text
下压蓄力 → 弹起/转体 → 落地压缩 → 反向弹起 → 回首帧
```
OneShot Emote
```text
Anticipation → 主表演 → 次级跟随 → Recovery → FadeOut
```
若产品明确要求角色在 Emote 资产内部完整归位,可添加 identity/基准姿态帧,但必须在 Motion Spec 标记 `endStrategy = "ReturnInAsset"`,并验证不会与底层 Idle 产生双重归位。
## 9. Combo
每段 Combo 都要定义:
- 可接续窗口;
- marker/服务端时序;
- 被打断时的 FadeOut
- 上一段 Recovery 是否会被下一段 Anticipation 替代。
Combo 动画通常不应各自强制回 Idle。前段 Follow-through 可以直接成为下一段 Anticipation 的起点,减少跳帧。
## 10. 设计原则
| 原则 | 应用 |
|---|---|
| Anticipation | 主动作前先做反方向蓄力 |
| Overshoot | 关键动作略越过最终目标,再 Recovery |
| Follow-through | Head/Hands/装饰比主体稍晚停止 |
| Hit pause | 用极短保持提高命中感 |
| Arc | 手脚沿弧线运动,必要时加中间帧 |
| Weight shift | 躯干和支撑腿响应主动作 |
| Squash/Stretch | Q 版跳跃通过下压和伸展强化弹性 |
| Silhouette | 关键姿态在主要摄像机中轮廓清晰 |
## 验证
- [ ] Motion Spec 明确 OneShot/Looped 和结束策略。
- [ ] Looped 首尾完全闭环。
- [ ] OneShot 末帧是有意设计的 Recovery。
- [ ] 大跨度旋转有中间引导帧。
- [ ] 移动中动作不覆盖腿部 locomotion。
- [ ] marker 与视觉高光时刻一致。
- [ ] FadeOut、打断和 Combo 接续均已在真实播放层测试。

View File

@@ -0,0 +1,310 @@
# 动画项目集成:探测、发布与播放
本文提供通用决策路径。文中的注册表和播放接口均为示例,不代表目标项目一定存在。
## 1. 先探测项目
### Rojo 与源码
读取项目实际的 Rojo project 文件,确认:
- 配置模块映射到哪里;
- 客户端 Controller/Component 在哪里;
- 共享模块和服务端代码边界;
- 开发工具或构建脚本是否会进入生产包;
- `.rbxm/.rbxmx` 等资源文件是否由仓库管理。
### 动画能力搜索
搜索:
```text
AnimationId
Animator
LoadAnimation
AnimationTrack
GetMarkerReachedSignal
KeyframeSequence
AnimationPriority
PreloadAsync
fadeTime
```
对每个候选模块记录:
```text
路径:
公开函数:
参数/返回值:
ID 来源:
Track 缓存:
停止/销毁:
调用方:
运行位置:
```
不要仅凭模块名推断 API。必须读实现和至少一个真实调用方。
### ID 存储搜索
常见但不保证存在的方式:
- 一个集中配置表;
- Model/Tool 上的 Attribute
- 每种角色或宠物的数据模块;
- 动作组件的局部常量;
- 由内容管线生成的数据。
优先沿用项目已经稳定使用的方式。若同一项目存在多种方式,应按目标对象的现有模式选择,而不是强行统一。
## 2. Rig 探测
标准 R15 可以使用标准层级参考;以下情况必须以实际 Motor6D 为准:
- 自定义比例或替换部件;
- 宠物/怪物使用 R15-like 命名;
- Rig 缺少手、脚或头部;
- 附加武器、翅膀、尾巴等自定义关节;
- 运行时才装配部分 Motor6D。
无 Studio MCP 时:
1. 在 Studio Explorer 展开目标 Rig。
2. 搜索或逐项查看 Motor6D 的 `Part0``Part1`
3.`Part1.Name` 和父子关系记录为 Pose 树。
4. 检查源码中是否有运行时装配脚本,并把动态关节纳入判断。
可在项目的开发环境中运行只读辅助脚本:
```lua
local target = workspace:FindFirstChild("TargetRig")
assert(target, "TargetRig not found")
for _, instance in target:GetDescendants() do
if instance:IsA("Motor6D") then
print(
instance.Name,
instance.Part0 and instance.Part0.Name or "nil",
"->",
instance.Part1 and instance.Part1.Name or "nil"
)
end
end
```
脚本输出只用于建模证据,不替代源码中的构建器。
## 3. 可复现的构建产物
优先把动画 Pose 数据和构建逻辑保存在项目源码中,例如:
```text
src/
└─ dev/
└─ animation-builders/
├─ HeavyAttack.luau
└─ build-animation.luau
```
实际路径必须服从项目结构。生产构建不应包含开发工具时,使用项目已有的开发目录排除方式;不要自行引入新工具链。
构建器建议返回:
```lua
export type BuildResult = {
sequence: KeyframeSequence,
motionSpecVersion: string,
}
```
至少保存:
- 动画名、fps、duration、looped、priority
- 每帧时间、Pose 层级、CFrame、Weight 和 easing
- marker 名称和时刻;
- OneShot 结束策略;
- 目标 Rig 类型或版本。
## 4. Studio 手工预览与发布
### 无 MCP 路径
1. 用项目声明版本启动 Rojo 服务。
2. 在 Studio 打开正确 Place连接对应 project。
3. 从 Explorer 确认构建 ModuleScript 路径。
4. 在 Command Bar 或项目现有开发工具中调用构建器。
调用示例(**路径和函数名仅示意**
```lua
local builder = require(game.ReplicatedStorage.Dev.AnimationBuilders.HeavyAttack)
local sequence = builder.build()
sequence.Parent = game.ServerStorage
```
5. 在 Explorer 核对 KeyframeSequence、Keyframe 时间和 Pose 树。
6. 选择匹配 Rig用 Animation Editor 导入或打开该序列。
7. 预览关键姿态、循环、marker、移动混合和 FadeOut。
8. 在 Animation Editor 中选择 Publish to Roblox。
9. 明确选择个人或 Group Creator、目标 Experience 和名称。
10. 发布成功后复制数字 ID并回填项目源码。
Studio 的菜单名称可能变化,应以当前 Animation Editor UI 为准。临时预览 ID、KeyframeSequence 对象和本地 Studio 缓存都不能作为线上正式 ID。
### 发布责任
正式发布是人工步骤,因为它涉及:
- Roblox 登录账号;
- 个人/Group 资产归属;
- Experience 权限;
- 资产审核和可用状态;
- 可能的所有权迁移。
Agent 应交付可复现序列和逐步操作,但不得声称源码已经自动发布,也不得编造占位数字为真实 ID。
### Studio MCP
Studio MCP 可用时,可以辅助检查 Rig、创建临时预览对象、读取属性或截图。先发现当前服务器支持的能力不依赖固定工具名。MCP 不可用不改变上述流程;正式发布始终是 Studio 人工步骤。
## 5. 选择 ID 接入方式
### A. 项目已有集中配置
按真实 schema 添加条目,并沿用命名、预载和校验规则。
示例(**仅说明一种可能结构**
```lua
return {
HeavyAttack = {
animationId = "rbxassetid://1234567890",
preload = true,
},
}
```
不要假定字段名是 `AnimationId`/`Preload`,也不要假定文件名或路径。
### B. 项目使用 Model Attribute
沿用现有 Attribute 名与读写位置。确认:
- 值是纯数字还是 `rbxassetid://` 字符串;
- Attribute 设置在模板还是运行时 clone
- 缺失值如何降级;
- 客户端是否能读取。
### C. 项目直接加载 ID
若这是当前局部模块的稳定模式,可使用统一常量/config table避免同一 ID 散落在多个调用点。只有重复使用和跨模块需求明确时才提出集中注册表。
## 6. 选择播放方式
### 已有播放模块
检查其真实能力:
- 按 key 或 asset ID 播放;
- loop、priority、speed、weight
- FadeIn/FadeOut
- Track 缓存和重复播放;
- marker connection
- Stop/StopAll
- model 销毁清理;
- 预载。
缺一项不等于必须重写模块;先判断本动画是否真的需要。
### 无播放模块
可沿用项目现有的直接 Animator 模式。以下只是最小职责示例:
```lua
local humanoid = model:FindFirstChildOfClass("Humanoid")
assert(humanoid, "Humanoid not found")
local animator = humanoid:FindFirstChildOfClass("Animator")
if not animator then
animator = Instance.new("Animator")
animator.Parent = humanoid
end
local animation = Instance.new("Animation")
animation.AnimationId = animationId
local track = animator:LoadAnimation(animation)
track.Priority = priority
track.Looped = looped
track:Play(fadeTime, weight, speed)
```
还需按项目语义补齐:
- `animation` 实例清理;
- Track 停止/销毁;
- marker connection 断开;
- 重复播放和角色重生;
- 加载失败与权限错误;
- 客户端/服务端播放位置及复制行为。
不要仅为一个动画建立复杂管理器。
## 7. Recovery 与播放层契约
### OneShot 默认
动画序列:
```text
Anticipation → Action → Follow-through → Recovery残余姿态
```
播放层:
```text
FadeIn → 播放 → FadeOut → 底层 Idle/Movement 接管
```
末帧不强制回 identity/Idle。否则常见问题是
- 动作结尾多出一段拖沓归位;
- 与实时 locomotion 的底层姿态不一致;
- Combo 切换出现“先回中立再攻击”的跳帧。
### 允许动画自身归位的情况
必须在 Motion Spec 中明确,例如:
- Looped末帧回到首帧以闭环
- 独立 Emote 明确要求完整归位;
- 录制/过场没有底层 locomotion 接管;
- 现有播放系统没有 FadeOut且修正播放层不在任务范围用户接受在资产内归位。
不要一边写“Recovery 结束”,一边在示例最后添加未说明的 Idle 帧。
## 8. Marker 与玩法权威
动画 marker 适合:
- 客户端 VFX
- 音效;
- 脚步或镜头反馈;
- 预判性 UI。
伤害、命中、资源消耗和冷却应由服务端权威逻辑决定。若玩法以 marker 对齐,服务端仍需有合法时序和状态校验,不能信任客户端 marker 事件。
连接 marker 后保存 `RBXScriptConnection`Track 停止、角色销毁或动作取消时断开。
## 9. 验收
- [ ] 所有模块、配置和路径都由当前项目源码证明。
- [ ] 目标 Rig 的 Motor6D/Pose 树已核对。
- [ ] 构建器和 Motion Spec 受版本控制。
- [ ] 无 Studio MCP 时也能构建、预览和发布。
- [ ] 正式 ID 由用户在正确 Creator/Experience 下发布。
- [ ] 项目注册表只在真实存在时使用。
- [ ] OneShot Recovery 与 FadeOut 契约一致。
- [ ] Looped 动画首尾闭环。
- [ ] Track、Animation 实例和 marker connection 有清理路径。
- [ ] 权限、审核、客户端/服务端复制行为已实测或列为风险。

View File

@@ -0,0 +1,235 @@
# R15 Rig Reference
标准 R15 可使用本文作为起点;目标项目的实际 Motor6D、比例和运行时装配始终优先。
## 标准 Pose 层级
```text
HumanoidRootPart
└─ LowerTorso
├─ UpperTorso
│ ├─ Head
│ ├─ LeftUpperArm
│ │ └─ LeftLowerArm
│ │ └─ LeftHand
│ └─ RightUpperArm
│ └─ RightLowerArm
│ └─ RightHand
├─ LeftUpperLeg
│ └─ LeftLowerLeg
│ └─ LeftFoot
└─ RightUpperLeg
└─ RightLowerLeg
└─ RightFoot
```
## 标准 Motor6D 对照
| Joint | Part0 | Part1 |
|---|---|---|
| Root | HumanoidRootPart | LowerTorso |
| Waist | LowerTorso | UpperTorso |
| Neck | UpperTorso | Head |
| LeftShoulder | UpperTorso | LeftUpperArm |
| RightShoulder | UpperTorso | RightUpperArm |
| LeftElbow | LeftUpperArm | LeftLowerArm |
| RightElbow | RightUpperArm | RightLowerArm |
| LeftWrist | LeftLowerArm | LeftHand |
| RightWrist | RightLowerArm | RightHand |
| LeftHip | LowerTorso | LeftUpperLeg |
| RightHip | LowerTorso | RightUpperLeg |
| LeftKnee | LeftUpperLeg | LeftLowerLeg |
| RightKnee | RightUpperLeg | RightLowerLeg |
| LeftAnkle | LeftLowerLeg | LeftFoot |
| RightAnkle | RightLowerLeg | RightFoot |
Pose 名通常对应 Motor6D 的 `Part1.Name`,嵌套关系对应 `Part0 → Part1`
## Pose.CFrame
- `Pose.CFrame` 是相对关节基准的附加变换。
- `CFrame.new()` 表示无附加偏移。
- 角度使用弧度,例如 `math.rad(15)`
- 平移应克制;角色世界位移通常由角色控制器或 root motion 方案负责。
- 自定义 Rig 的 Motor6D `C0/C1` 会影响视觉结果,同一角度不一定产生标准 R15 的姿势。
### 标准 R15 轴向起点
以下用于初始设计,必须在目标 Rig 上预览。
Torso/Head
| 轴 | 正值 | 负值 |
|---|---|---|
| X | 后仰/抬头 | 前倾/俯头 |
| Y | 一侧转向 | 反侧转向 |
| Z | 一侧倾斜 | 反侧倾斜 |
UpperArm
| 轴 | 常见效果 |
|---|---|
| X 正向 | 前举/上举 |
| X 负向 | 后摆 |
| Y | 沿局部轴扭转 |
| Z | 外展/内收,左右臂符号相反 |
UpperLeg
| 轴 | 常见效果 |
|---|---|
| X 正向 | 前踢 |
| X 负向 | 后蹬 |
| Y | 内外旋 |
| Z | 内收/外展 |
不要只看数字判断动作方向;用关键姿态预览验证。
## 从实际 Rig 生成层级事实
### 源码路径
先搜索:
- Rig 构建 ModuleScript
- Model 文件(`.rbxm/.rbxmx`
- 创建 Motor6D 的代码;
- 角色/宠物模板映射;
- 运行时附加武器、尾巴、翅膀的脚本。
如果源码能完整描述 Rig以源码为首要证据。
### Studio 手工路径
当模型只存在 Place
1. 在 Explorer 展开目标 Model。
2. 找出所有 Motor6D。
3. 逐项记录 `Name``Part0``Part1``C0``C1`
4. 检查 Humanoid/AnimationController 与 Animator。
5.`Part0 → Part1` 转成 Pose 树。
可用 Command Bar 运行:
```lua
local model = workspace:FindFirstChild("TargetRig")
assert(model, "TargetRig not found")
local rows = {}
for _, instance in model:GetDescendants() do
if instance:IsA("Motor6D") then
table.insert(rows, {
joint = instance.Name,
parent = instance.Part0 and instance.Part0.Name or "nil",
child = instance.Part1 and instance.Part1.Name or "nil",
})
end
end
table.sort(rows, function(a, b)
return a.child < b.child
end)
for _, row in rows do
print(row.joint, row.parent, "->", row.child)
end
```
Studio MCP 可用时也只能作为该检查的辅助;工具不可用时按上述流程完成。
## 构建 Pose 树
```lua
local function makePose(data)
local pose = Instance.new("Pose")
pose.Name = data.name
pose.CFrame = data.cframe or CFrame.new()
pose.Weight = if data.weight == nil then 1 else data.weight
pose.EasingStyle = data.easingStyle or Enum.PoseEasingStyle.Cubic
pose.EasingDirection =
data.easingDirection or Enum.PoseEasingDirection.InOut
for _, child in data.children or {} do
pose:AddSubPose(makePose(child))
end
return pose
end
local function makeKeyframe(time, rootData)
local keyframe = Instance.new("Keyframe")
keyframe.Time = time
keyframe:AddPose(makePose(rootData))
return keyframe
end
```
标准根数据示例:
```lua
local root = {
name = "HumanoidRootPart",
cframe = CFrame.new(),
children = {
{
name = "LowerTorso",
cframe = CFrame.Angles(math.rad(-5), 0, 0),
children = {
{
name = "UpperTorso",
cframe = CFrame.Angles(math.rad(-10), 0, 0),
},
},
},
},
}
```
## 自定义宠物/R15-like Rig
常见差异:
- 缺少 Hands/Feet
- 四足使用自定义腿名;
- Head 不在 UpperTorso 下;
- Tail/Wing/Weapon 使用额外 Motor6D
- 只有 AnimationController没有 Humanoid
- 同名部件重复导致按 Name 映射不唯一。
处理原则:
1. 只为实际 Motor6D 链创建 Pose。
2. 不把“缺失部件会被忽略”当作生成错误 Pose 的理由。
3. 对重复 Part 名先修正 Rig 或使用项目已有唯一标识策略。
4. 额外关节也按真实父子关系嵌套。
5. 在不同缩放和皮肤版本上检查穿模。
## 上半身动画
移动中攻击/施法通常:
- 不包含腿部 Pose
- 不向 HumanoidRootPart 写入位移;
- 根据项目混合结果决定 Root/LowerTorso 是省略变换、保留层级并设 Weight 0还是使用很小权重
- UpperTorso 仍可驱动动作,但要验证它与底层 locomotion 的合成。
Pose Weight、轨道 Weight 和 AnimationPriority 共同影响结果。不要用单一规则替代运行时验证。
## 大跨度旋转
手臂从下垂到头后、武器从一侧跨到另一侧时,插值可能沿非预期路径。处理方式:
1. 添加中间 Pose 引导弧线;
2. 分拆肩、肘和躯干旋转;
3. 在主要摄像机和对侧视角预览;
4. 检查负缩放、镜像 Rig 和附件是否放大穿模。
## Rig 验收
- [ ] Pose 树由目标 Motor6D 证据生成。
- [ ] Part0/Part1 与嵌套关系一致。
- [ ] 没有为缺失部件生成无意义 Pose。
- [ ] 自定义关节已覆盖。
- [ ] 标准 R15 轴向已在目标 Rig 上实测。
- [ ] 上半身动作与 locomotion 混合无滑步。
- [ ] 大跨度旋转有引导帧且无反向扭曲。

View File

@@ -0,0 +1,88 @@
---
name: roblox-asset-uploader
description: 通过 Roblox Open Cloud Assets API 安全校验并上传本地图片、音频、模型或贴花,支持单文件、批量计划、异步结果轮询和 Asset ID 输出。用户要求上传 Roblox 资源、批量上传、获取 rbxassetid 或检查资源上传流程时使用。
---
# Roblox Asset Uploader
使用 `scripts/upload.py` 执行资源上传。脚本仅依赖 Python 3.10 及以上版本的标准库。
## 安全边界
资源上传是外部不可逆写操作,必须遵守以下顺序:
1. 确认资源文件、资源类型、显示名称和目标 Creator。
2. 先运行同参数的 `--dry-run`。此模式只读取配置、校验文件并打印计划,不联网。
3. 向用户展示 dry-run 输出中的 Creator、每个文件、资源类型和字节大小。
4. 明确询问用户是否确认执行这一次计划。确认只对当前展示的 Creator、文件集合、类型和名称有效。
5. 仅在用户明确确认后,移除 `--dry-run` 并加入 `--confirm` 执行同一调用。
6. 计划有任何变化时,原确认失效;重新 dry-run 并再次确认。
没有用户针对本次调用的明确确认,禁止使用 `--confirm`。不得把 manifest、模板或示例中的占位值当作真实 API key。
## 私密配置
默认读取仓库根目录的 `.cursor/local-env.json`
- `roblox_open_cloud_api_key`
- `roblox_creator_type``User``Group`
- `roblox_creator_id`:正整数
下列环境变量可覆盖 JSON 中的对应值:
- `ROBLOX_API_KEY`
- `ROBLOX_CREATOR_TYPE`
- `ROBLOX_CREATOR_ID`
需要改用其他本机 JSON 文件时传入 `--local-env`。脚本没有 API key 命令行参数,也不会交互询问 secret。禁止在命令、聊天、日志或提交内容中输出真实 key。
## 调用方式
从 Skill 目录运行,或把脚本路径替换为当前 Skill 的绝对路径。
单文件 dry-run
```powershell
python scripts/upload.py --file path/to/icon.png --type Image --name GameIcon --dry-run
```
用户确认相同计划后:
```powershell
python scripts/upload.py --file path/to/icon.png --type Image --name GameIcon --confirm
```
批量 dry-run
```powershell
python scripts/upload.py --batch path/to/images --type Image --batch-interval 1 --dry-run
```
用户确认相同计划后:
```powershell
python scripts/upload.py --batch path/to/images --type Image --batch-interval 1 --confirm
```
## 参数
- `--file PATH`:上传一个文件。
- `--batch DIR`:上传目录中与 `--type` 匹配的直属文件,不递归。
- `--type {Image,Audio,Model,Decal}`:必填资源类型。
- `--name NAME`:单文件显示名称;省略时使用文件名主干。
- `--local-env PATH`:覆盖默认本机 JSON 配置路径。
- `--dry-run`:只校验并列出计划,保证零网络调用。
- `--confirm`:允许执行本次真实上传。
- `--batch-interval SECONDS`:批量文件之间的等待时间,默认 1 秒。
`--file``--batch` 二选一。批量模式不接受统一 `--name`,每个文件使用各自文件名主干。
## 结果与失败处理
- 成功输出 `rbxassetid://<Asset ID>`
- 批量上传逐文件报告结果;部分失败时进程返回非零状态。
- `429``5xx` 和临时网络错误会进行有限指数退避。
- API key 不会进入正常输出;服务端错误正文会截断并脱敏。
- 不要因失败直接重复真实上传。先判断服务端是否已创建异步 operation避免产生重复资源不确定时向用户报告并停止。
资源格式、请求结构、operation URL 规则和故障排查见 [reference.md](references/reference.md)。

View File

@@ -0,0 +1,124 @@
# Roblox Open Cloud Assets API 上传参考
## 请求端点
创建资源:
```text
POST https://apis.roblox.com/assets/v1/assets
```
请求头包含:
```text
x-api-key: <本机私密配置中的值>
Content-Type: multipart/form-data; boundary=...
Accept: application/json
```
真实 key 只能来自 `.cursor/local-env.json` 或进程环境变量,不得写入命令、文档或版本库。
## multipart 结构
脚本使用 CRLF 和唯一 boundary 构造两个 part
1. `request``application/json; charset=utf-8`
2. `fileContent`:资源文件二进制和对应 MIME 类型
`request` 的 JSON 结构:
```json
{
"assetType": "Image",
"displayName": "GameIcon",
"description": "",
"creationContext": {
"creator": {
"userId": 123456789
}
}
}
```
Group Creator 使用 `groupId`User Creator 使用 `userId`
## operation URL
创建接口可能直接返回 `assetId`,也可能返回异步 operation
```json
{
"path": "operations/example",
"done": false
}
```
operation path 必须相对 API host 解析,不能拼到 Assets endpoint 后面:
```text
operations/example
→ https://apis.roblox.com/operations/example
```
`/` 开头的 path 同样解析到 `https://apis.roblox.com/`。API 返回完整 HTTPS URL 时保持原 URL。其他 scheme 会被拒绝。
轮询使用 `GET` 和相同的 `x-api-key``done``true` 后,从 `response.assetId``response.id``response.path` 读取 Asset ID。
## 配置优先级
默认 JSON 文件是仓库根 `.cursor/local-env.json`,键名为:
- `roblox_open_cloud_api_key`
- `roblox_creator_type`
- `roblox_creator_id`
环境变量逐项覆盖 JSON
- `ROBLOX_API_KEY`
- `ROBLOX_CREATOR_TYPE`
- `ROBLOX_CREATOR_ID`
脚本拒绝空 key 和常见模板占位形式,拒绝非 `User`/`Group` Creator 类型以及非正整数 Creator ID。配置缺失时直接报错不进行交互输入。
## 文件校验
当前脚本执行下列本地预检:
| 资源类型 | 扩展名 | 本地大小上限 |
|---|---|---:|
| `Image` | `.png``.jpg``.jpeg``.bmp``.tga` | 20 MiB |
| `Decal` | `.png``.jpg``.jpeg``.bmp``.tga` | 20 MiB |
| `Audio` | `.mp3``.ogg` | 7 MiB |
| `Model` | `.fbx` | 50 MiB |
平台限制和账号资格可能变化,本地校验通过不代表平台一定接受。遇到平台拒绝时,以 Roblox Creator 文档和返回错误为准,不要通过放宽脚本校验绕过平台规则。
## 重试与错误处理
- `429``5xx``URLError`:最多重试 3 次,等待时间依次为 1、2、4 秒。
- 其他 HTTP 错误:不自动重试。
- 批量上传默认在文件之间等待 1 秒,可通过 `--batch-interval` 调整为非负数。
- 错误正文最多显示 1000 个字符。
- 输出前会替换 API key异常信息不得包含请求头或完整配置对象。
由于创建资源不可逆,发生超时或不确定状态时不要立即重传同一文件。先保留并检查 API 返回的 operation path无法判断是否已创建时停止并向用户说明。
## dry-run 与确认
`--dry-run` 会完成:
1. 读取并校验配置;
2. 校验文件存在性、扩展名、大小和显示名称;
3. 按确定顺序列出 Creator、文件、类型、名称和字节大小。
它不会构造或发起网络调用。真实上传还必须显式传入 `--confirm`该参数只表示脚本层允许执行Agent 仍必须先展示 dry-run 计划并取得用户对本次计划的明确确认。
## 本地测试
在 Skill 目录运行:
```powershell
python -m unittest scripts/test_upload.py
```
测试会替换所有网络入口,不会连接 Roblox。

View File

@@ -0,0 +1,229 @@
from __future__ import annotations
from contextlib import redirect_stderr, redirect_stdout
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
import urllib.error
import urllib.request
SCRIPT_PATH = Path(__file__).with_name("upload.py")
sys.dont_write_bytecode = True
SPEC = importlib.util.spec_from_file_location(
"roblox_asset_uploader_under_test", SCRIPT_PATH
)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"无法加载 {SCRIPT_PATH}")
UPLOADER = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = UPLOADER
SPEC.loader.exec_module(UPLOADER)
class FakeResponse:
def __init__(self, payload: dict[str, object]) -> None:
self._body = json.dumps(payload).encode("utf-8")
def __enter__(self) -> "FakeResponse":
return self
def __exit__(self, *args: object) -> None:
return None
def read(self, _limit: int = -1) -> bytes:
return self._body
class AssetUploadTests(unittest.TestCase):
def setUp(self) -> None:
self.network_patch = mock.patch.object(
UPLOADER.urllib.request,
"urlopen",
side_effect=AssertionError("测试禁止真实联网"),
)
self.urlopen = self.network_patch.start()
self.addCleanup(self.network_patch.stop)
def _write_config(
self,
root: Path,
*,
api_key: str = "unit-test-secret",
creator_type: str = "User",
creator_id: str = "12345",
) -> Path:
path = root / "local-env.json"
path.write_text(
json.dumps(
{
"roblox_open_cloud_api_key": api_key,
"roblox_creator_type": creator_type,
"roblox_creator_id": creator_id,
}
),
encoding="utf-8",
newline="\n",
)
return path
def test_dry_run_never_uses_network(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
config_path = self._write_config(root)
asset_path = root / "icon.png"
asset_path.write_bytes(b"not-a-real-image")
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
code = UPLOADER.main(
[
"--file",
str(asset_path),
"--type",
"Image",
"--local-env",
str(config_path),
"--dry-run",
]
)
self.assertEqual(code, 0)
self.assertIn("CreatorUser 12345", stdout.getvalue())
self.assertIn("未发起任何网络请求", stdout.getvalue())
self.assertEqual(stderr.getvalue(), "")
self.urlopen.assert_not_called()
def test_operation_url_uses_api_origin(self) -> None:
self.assertEqual(
UPLOADER.operation_url("operations/example"),
"https://apis.roblox.com/operations/example",
)
absolute = "https://apis.roblox.com/assets/v1/operations/example"
self.assertEqual(UPLOADER.operation_url(absolute), absolute)
self.urlopen.assert_not_called()
def test_secret_is_redacted_from_error_output(self) -> None:
secret = "secret-value-that-must-not-leak"
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
config_path = self._write_config(root, api_key=secret)
asset_path = root / "icon.png"
asset_path.write_bytes(b"image")
body = json.dumps({"message": f"rejected {secret}"}).encode("utf-8")
self.urlopen.side_effect = urllib.error.HTTPError(
UPLOADER.UPLOAD_URL,
401,
"Unauthorized",
{},
io.BytesIO(body),
)
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
code = UPLOADER.main(
[
"--file",
str(asset_path),
"--type",
"Image",
"--local-env",
str(config_path),
"--confirm",
]
)
output = stdout.getvalue() + stderr.getvalue()
self.assertEqual(code, 1)
self.assertNotIn(secret, output)
self.assertIn("[REDACTED]", output)
self.assertEqual(self.urlopen.call_count, 1)
def test_429_is_retried_with_exponential_backoff(self) -> None:
throttled = urllib.error.HTTPError(
UPLOADER.UPLOAD_URL,
429,
"Rate Limited",
{},
io.BytesIO(b'{"message":"slow down"}'),
)
self.urlopen.side_effect = [
throttled,
FakeResponse({"assetId": "98765"}),
]
sleeper = mock.Mock()
request = urllib.request.Request(
UPLOADER.UPLOAD_URL,
headers={"x-api-key": "unit-test-secret"},
method="POST",
data=b"payload",
)
result = UPLOADER.request_json(
request,
"unit-test-secret",
retries=3,
sleeper=sleeper,
)
self.assertEqual(result["assetId"], "98765")
self.assertEqual(self.urlopen.call_count, 2)
sleeper.assert_called_once_with(1.0)
def test_missing_confirm_refuses_upload(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
config_path = self._write_config(root)
asset_path = root / "icon.png"
asset_path.write_bytes(b"image")
stderr = io.StringIO()
with redirect_stdout(io.StringIO()), redirect_stderr(stderr):
code = UPLOADER.main(
[
"--file",
str(asset_path),
"--type",
"Image",
"--local-env",
str(config_path),
]
)
self.assertEqual(code, 2)
self.assertIn("缺少 --confirm", stderr.getvalue())
self.urlopen.assert_not_called()
def test_local_env_is_read_and_environment_overrides_it(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
config_path = self._write_config(
root,
api_key="json-secret",
creator_type="Group",
creator_id="111",
)
config = UPLOADER.load_config(
config_path,
environ={
"ROBLOX_API_KEY": "environment-secret",
"ROBLOX_CREATOR_ID": "222",
},
)
self.assertEqual(config.api_key, "environment-secret")
self.assertEqual(config.creator_type, "Group")
self.assertEqual(config.creator_id, 222)
self.assertEqual(config.local_env_path, config_path)
self.urlopen.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""使用 Python 标准库安全上传 Roblox Open Cloud 资源。"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import json
import mimetypes
import os
from pathlib import Path
import sys
import time
from typing import Mapping, Sequence
import urllib.error
import urllib.parse
import urllib.request
import uuid
UPLOAD_URL = "https://apis.roblox.com/assets/v1/assets"
API_ORIGIN = "https://apis.roblox.com/"
DEFAULT_BATCH_INTERVAL = 1.0
POLL_INTERVAL = 2.0
POLL_TIMEOUT = 120.0
REQUEST_TIMEOUT = 60.0
MAX_RETRIES = 3
BACKOFF_BASE = 1.0
ERROR_BODY_LIMIT = 1000
ERROR_BODY_READ_LIMIT = 16 * 1024
SUCCESS_BODY_LIMIT = 2 * 1024 * 1024
TYPE_EXTENSIONS = {
"Image": {".png", ".jpg", ".jpeg", ".bmp", ".tga"},
"Audio": {".mp3", ".ogg"},
"Model": {".fbx"},
"Decal": {".png", ".jpg", ".jpeg", ".bmp", ".tga"},
}
TYPE_SIZE_LIMITS = {
"Image": 20 * 1024 * 1024,
"Audio": 7 * 1024 * 1024,
"Model": 50 * 1024 * 1024,
"Decal": 20 * 1024 * 1024,
}
MIME_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".bmp": "image/bmp",
".tga": "image/tga",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".fbx": "model/fbx",
}
class UploaderError(RuntimeError):
"""可安全展示给用户的上传器错误。"""
class ConfirmationRequired(UploaderError):
"""真实上传缺少显式确认。"""
@dataclass(frozen=True)
class Config:
api_key: str
creator_type: str
creator_id: int
local_env_path: Path
@dataclass(frozen=True)
class UploadItem:
path: Path
asset_type: str
display_name: str
size: int
mime_type: str
def default_local_env_path() -> Path:
"""定位包含脚本的仓库根目录下的本机配置。"""
script_path = Path(__file__).resolve()
for parent in script_path.parents:
cursor_dir = parent / ".cursor"
if cursor_dir.is_dir():
return cursor_dir / "local-env.json"
return Path.cwd() / ".cursor" / "local-env.json"
def _read_local_env(path: Path) -> dict[str, object]:
if not path.exists():
return {}
if not path.is_file():
raise UploaderError(f"本机配置路径不是文件:{path}")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise UploaderError(f"无法读取本机配置 {path}{exc}") from exc
if not isinstance(data, dict):
raise UploaderError(f"本机配置必须是 JSON 对象:{path}")
return data
def _configured_value(
data: Mapping[str, object],
environ: Mapping[str, str],
env_name: str,
json_name: str,
) -> str:
raw = environ[env_name] if env_name in environ else data.get(json_name, "")
return str(raw).strip() if raw is not None else ""
def _looks_like_placeholder(value: str) -> bool:
normalized = value.strip().lower()
if not normalized:
return False
if (
(normalized.startswith("<") and normalized.endswith(">"))
or (normalized.startswith("${") and normalized.endswith("}"))
or (normalized.startswith("{{") and normalized.endswith("}}"))
):
return True
return (
"placeholder" in normalized
or normalized.startswith("your_")
or normalized.startswith("replace_")
or normalized in {"changeme", "change-me", "todo", "unset"}
)
def load_config(
local_env_path: Path | str | None = None,
environ: Mapping[str, str] | None = None,
) -> Config:
"""读取 JSON 配置,并让进程环境变量覆盖同名配置。"""
path = (
Path(local_env_path).expanduser()
if local_env_path is not None
else default_local_env_path()
)
data = _read_local_env(path)
env = os.environ if environ is None else environ
api_key = _configured_value(
data, env, "ROBLOX_API_KEY", "roblox_open_cloud_api_key"
)
creator_type_raw = _configured_value(
data, env, "ROBLOX_CREATOR_TYPE", "roblox_creator_type"
)
creator_id_raw = _configured_value(
data, env, "ROBLOX_CREATOR_ID", "roblox_creator_id"
)
missing = []
if not api_key:
missing.append("roblox_open_cloud_api_key / ROBLOX_API_KEY")
if not creator_type_raw:
missing.append("roblox_creator_type / ROBLOX_CREATOR_TYPE")
if not creator_id_raw:
missing.append("roblox_creator_id / ROBLOX_CREATOR_ID")
if missing:
raise UploaderError(
f"缺少必要配置({', '.join(missing)});配置文件:{path}"
)
if _looks_like_placeholder(api_key):
raise UploaderError(
"API key 仍是占位值;请在本机配置或 ROBLOX_API_KEY 中提供真实值"
)
creator_type_lookup = {"user": "User", "group": "Group"}
creator_type = creator_type_lookup.get(creator_type_raw.lower())
if creator_type is None:
raise UploaderError("Creator 类型必须是 User 或 Group")
if not creator_id_raw.isdecimal() or int(creator_id_raw) <= 0:
raise UploaderError("Creator ID 必须是正整数")
return Config(
api_key=api_key,
creator_type=creator_type,
creator_id=int(creator_id_raw),
local_env_path=path,
)
def _validate_display_name(name: str) -> str:
display_name = name.strip()
if not display_name:
raise UploaderError("资源名称不能为空")
if "\x00" in display_name or "\r" in display_name or "\n" in display_name:
raise UploaderError("资源名称不能包含 NUL 或换行符")
return display_name
def _item_for(path: Path, asset_type: str, display_name: str) -> UploadItem:
if not path.exists():
raise UploaderError(f"文件不存在:{path}")
if not path.is_file():
raise UploaderError(f"路径不是文件:{path}")
suffix = path.suffix.lower()
allowed = TYPE_EXTENSIONS[asset_type]
if suffix not in allowed:
allowed_text = ", ".join(sorted(allowed))
raise UploaderError(
f"文件 {path.name} 与类型 {asset_type} 不匹配;允许扩展名:{allowed_text}"
)
try:
size = path.stat().st_size
except OSError as exc:
raise UploaderError(f"无法读取文件大小 {path}{exc}") from exc
if size <= 0:
raise UploaderError(f"文件不能为空:{path}")
limit = TYPE_SIZE_LIMITS[asset_type]
if size > limit:
raise UploaderError(
f"文件超过 {asset_type} 的大小上限:{path}{size} > {limit} bytes"
)
mime_type = MIME_TYPES.get(suffix) or mimetypes.guess_type(path.name)[0]
if not mime_type:
raise UploaderError(f"无法确定文件 MIME 类型:{path}")
return UploadItem(
path=path.resolve(),
asset_type=asset_type,
display_name=_validate_display_name(display_name),
size=size,
mime_type=mime_type,
)
def collect_items(
file_path: str | None,
batch_path: str | None,
asset_type: str,
name: str,
) -> list[UploadItem]:
"""校验单文件或批量目录并生成确定顺序的上传计划。"""
if file_path:
path = Path(file_path).expanduser()
return [_item_for(path, asset_type, name or path.stem)]
if name:
raise UploaderError("--name 仅可与 --file 一起使用")
if not batch_path:
raise UploaderError("必须提供 --file 或 --batch")
directory = Path(batch_path).expanduser()
if not directory.exists():
raise UploaderError(f"批量目录不存在:{directory}")
if not directory.is_dir():
raise UploaderError(f"批量路径不是目录:{directory}")
allowed = TYPE_EXTENSIONS[asset_type]
candidates = sorted(
(
path
for path in directory.iterdir()
if path.is_file() and path.suffix.lower() in allowed
),
key=lambda path: path.name.casefold(),
)
if not candidates:
raise UploaderError(f"批量目录中没有可上传的 {asset_type} 文件:{directory}")
return [_item_for(path, asset_type, path.stem) for path in candidates]
def print_plan(items: Sequence[UploadItem], config: Config, dry_run: bool) -> None:
mode = "DRY-RUN" if dry_run else "CONFIRMED UPLOAD"
total_size = sum(item.size for item in items)
print(f"模式:{mode}")
print(f"Creator{config.creator_type} {config.creator_id}")
print(f"文件数:{len(items)};总大小:{total_size} bytes")
for item in items:
print(
f"- {item.path} | 类型={item.asset_type} | "
f"名称={item.display_name} | 大小={item.size} bytes"
)
def _safe_filename(filename: str) -> str:
return (
filename.replace("\\", "_")
.replace('"', "_")
.replace("\r", "_")
.replace("\n", "_")
)
def build_multipart(item: UploadItem, config: Config) -> tuple[bytes, str]:
"""构造 Roblox Assets API 所需的 multipart/form-data 请求体。"""
boundary = f"----roblox-uploader-{uuid.uuid4().hex}"
creator_key = "userId" if config.creator_type == "User" else "groupId"
metadata = {
"assetType": item.asset_type,
"displayName": item.display_name,
"description": "",
"creationContext": {"creator": {creator_key: config.creator_id}},
}
metadata_bytes = json.dumps(
metadata, ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
try:
file_bytes = item.path.read_bytes()
except OSError as exc:
raise UploaderError(f"无法读取待上传文件 {item.path}{exc}") from exc
filename = _safe_filename(item.path.name)
chunks = [
f"--{boundary}\r\n".encode("ascii"),
b'Content-Disposition: form-data; name="request"\r\n',
b"Content-Type: application/json; charset=utf-8\r\n\r\n",
metadata_bytes,
b"\r\n",
f"--{boundary}\r\n".encode("ascii"),
(
'Content-Disposition: form-data; name="fileContent"; '
f'filename="{filename}"\r\n'
).encode("utf-8"),
f"Content-Type: {item.mime_type}\r\n\r\n".encode("ascii"),
file_bytes,
b"\r\n",
f"--{boundary}--\r\n".encode("ascii"),
]
return b"".join(chunks), boundary
def redact_secret(text: str, secret: str) -> str:
if not secret:
return text
redacted = text.replace(secret, "[REDACTED]")
# 若受限读取恰好截断了 secret连同末尾的可识别前缀一起移除。
max_prefix = min(len(secret) - 1, len(redacted))
for length in range(max_prefix, 7, -1):
prefix = secret[:length]
if redacted.endswith(prefix):
return redacted[: -length] + "[REDACTED]"
return redacted
def _bounded_error_body(raw: bytes, api_key: str) -> str:
text = raw.decode("utf-8", errors="replace")
text = redact_secret(text, api_key).replace("\r", " ").replace("\n", " ")
if len(text) > ERROR_BODY_LIMIT:
return text[:ERROR_BODY_LIMIT] + ""
return text
def _decode_success_body(raw: bytes) -> dict[str, object]:
if not raw:
raise UploaderError("API 返回了空响应")
if len(raw) > SUCCESS_BODY_LIMIT:
raise UploaderError("API 响应超过安全大小限制")
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise UploaderError("API 返回了无效 JSON") from exc
if not isinstance(data, dict):
raise UploaderError("API JSON 响应必须是对象")
return data
def request_json(
request: urllib.request.Request,
api_key: str,
*,
retries: int = MAX_RETRIES,
sleeper=None,
) -> dict[str, object]:
"""执行 JSON HTTP 调用,对临时故障进行有限指数退避。"""
sleep = time.sleep if sleeper is None else sleeper
for attempt in range(retries + 1):
try:
with urllib.request.urlopen(
request, timeout=REQUEST_TIMEOUT
) as response:
raw = response.read(SUCCESS_BODY_LIMIT + 1)
return _decode_success_body(raw)
except urllib.error.HTTPError as exc:
raw = exc.read(ERROR_BODY_READ_LIMIT)
retryable = exc.code == 429 or 500 <= exc.code <= 599
if retryable and attempt < retries:
sleep(BACKOFF_BASE * (2**attempt))
continue
body = _bounded_error_body(raw, api_key)
detail = f"HTTP {exc.code}"
if body:
detail += f"{body}"
raise UploaderError(detail) from exc
except urllib.error.URLError as exc:
if attempt < retries:
sleep(BACKOFF_BASE * (2**attempt))
continue
reason = redact_secret(str(exc.reason), api_key)
raise UploaderError(f"网络请求失败:{reason}") from exc
raise UploaderError("网络请求在重试后仍失败")
def operation_url(path: str) -> str:
"""把 API 返回的 operation path 转成可轮询的绝对 HTTPS URL。"""
value = path.strip()
if not value:
raise UploaderError("API 响应缺少 operation path")
parsed = urllib.parse.urlsplit(value)
if parsed.scheme or parsed.netloc:
if parsed.scheme == "https" and parsed.netloc:
return value
raise UploaderError("operation URL 必须使用 HTTPS")
return urllib.parse.urljoin(API_ORIGIN, value.lstrip("/"))
def _extract_asset_id(data: Mapping[str, object]) -> str | None:
for key in ("assetId", "id"):
value = data.get(key)
if value is not None and str(value).strip():
return str(value).strip()
path = data.get("path")
if isinstance(path, str) and path.startswith("assets/"):
candidate = path.rsplit("/", 1)[-1]
if candidate:
return candidate
return None
def poll_operation(path: str, api_key: str) -> dict[str, object]:
url = operation_url(path)
deadline = time.monotonic() + POLL_TIMEOUT
while time.monotonic() < deadline:
request = urllib.request.Request(
url,
headers={"x-api-key": api_key, "Accept": "application/json"},
method="GET",
)
data = request_json(request, api_key)
if data.get("done") is True:
error = data.get("error")
if error:
detail = redact_secret(
json.dumps(error, ensure_ascii=False), api_key
)
raise UploaderError(f"异步操作失败:{detail[:ERROR_BODY_LIMIT]}")
response = data.get("response", data)
if not isinstance(response, dict):
raise UploaderError("异步操作响应格式无效")
return response
time.sleep(POLL_INTERVAL)
raise UploaderError(f"异步操作轮询超时:{url}")
def upload_item(item: UploadItem, config: Config) -> str:
body, boundary = build_multipart(item, config)
request = urllib.request.Request(
UPLOAD_URL,
data=body,
headers={
"x-api-key": config.api_key,
"Accept": "application/json",
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
method="POST",
)
data = request_json(request, config.api_key)
asset_id = _extract_asset_id(data)
if asset_id:
return asset_id
path = data.get("path")
if not isinstance(path, str):
raise UploaderError("上传响应中既没有 Asset ID也没有 operation path")
final = poll_operation(path, config.api_key)
asset_id = _extract_asset_id(final)
if not asset_id:
raise UploaderError("异步操作完成,但响应中没有 Asset ID")
return asset_id
def execute_uploads(
items: Sequence[UploadItem],
config: Config,
batch_interval: float,
) -> bool:
had_error = False
for index, item in enumerate(items):
try:
asset_id = upload_item(item, config)
print(f"上传成功:{item.path.name} -> rbxassetid://{asset_id}")
except UploaderError as exc:
had_error = True
message = redact_secret(str(exc), config.api_key)
print(f"上传失败:{item.path.name}{message}", file=sys.stderr)
if index + 1 < len(items):
time.sleep(batch_interval)
return not had_error
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="通过 Roblox Open Cloud Assets API 上传资源"
)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--file", help="单个资源文件")
source.add_argument("--batch", help="批量资源目录(不递归)")
parser.add_argument(
"--type",
required=True,
choices=tuple(TYPE_EXTENSIONS),
help="资源类型",
)
parser.add_argument("--name", default="", help="单文件资源显示名称")
parser.add_argument(
"--local-env",
help="本机 JSON 配置路径;默认使用仓库根 .cursor/local-env.json",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="只校验配置和文件并打印计划,不联网",
)
parser.add_argument(
"--confirm",
action="store_true",
help="确认执行本次不可逆上传;应先取得用户确认",
)
parser.add_argument(
"--batch-interval",
type=float,
default=DEFAULT_BATCH_INTERVAL,
help="批量上传请求之间的秒数(默认 1",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
config: Config | None = None
try:
if args.batch_interval < 0:
raise UploaderError("--batch-interval 不能小于 0")
config = load_config(args.local_env)
items = collect_items(args.file, args.batch, args.type, args.name)
print_plan(items, config, args.dry_run)
if args.dry_run:
print("DRY-RUN 完成:未发起任何网络请求。")
return 0
if not args.confirm:
raise ConfirmationRequired(
"拒绝真实上传:缺少 --confirm。请先运行 --dry-run"
"向用户展示计划并取得针对本次调用的明确确认。"
)
return 0 if execute_uploads(items, config, args.batch_interval) else 1
except ConfirmationRequired as exc:
print(str(exc), file=sys.stderr)
return 2
except UploaderError as exc:
secret = config.api_key if config is not None else ""
print(f"错误:{redact_secret(str(exc), secret)}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,139 @@
---
name: roblox-camera-cinematics
description: 设计、实现、调参与验证 Roblox 运镜、过场、CameraEvent、Scriptable 相机、FOV、shake、skip、fallback 和恢复流程。遇到镜头事件、Boss 登场、奖励聚焦、技能演出、胜利镜头或相机舒适度 QA 时使用;先探测项目 camera/input 能力,再落地数据驱动方案。
---
# Roblox Camera Cinematics
把镜头意图转为数据驱动 `CameraEvent`、客户端播放、skip / fallback / restore 和舒适度验收。主流程使用中文Roblox API 和类型名保留英文。
## 核心约束
1. 先探测现有 camera、input、UI、配置、远端和生命周期模块不按固定目录或模块名猜测。
2. 优先复用项目已有相机所有权与输入锁定接口;不存在时才设计最小适配层。
3. 特殊镜头采用数据驱动 `CameraEvent`;玩法系统只触发 event ID 和安全上下文,不硬编码完整镜头路径。
4. 相机播放是客户端表现。战斗、奖励、命中和进度仍由服务器权威验证。
5. `Play`、正常结束、`Skip`、取消、目标失效、死亡、重生和异常必须汇入同一个 `Restore` / finalizer。
6. 保存并恢复 `CameraType``CameraSubject``CFrame``FieldOfView` 以及由本次事件修改的输入状态。
7. 手机默认采用保守 shake、旋转、roll 和 FOV 变化;舒适度优先于镜头冲击力。
## 按需读取
- [CameraEvent 工作流与最小 Luau 示例](references/camera-event-workflow.md)
## 工作模式
- **设计**事件意图、shot list、配置草案、fallback、skip、舒适度和 QA。
- **实现**:探测项目后,实现或扩展数据驱动播放器。
- **调参**:默认只改配置;除非用户明确要求,不改运行时。
- **预览**:使用项目已有开发工具,或设计与运行时隔离的 Studio-only 预览。
- **验证 / 审查**:优先报告相机所有权冲突、恢复缺口、目标失效、输入卡死和舒适度风险。
## 1. 项目探测 Gate
实现前查找并记录:
### Camera
- `workspace.CurrentCamera` 的写入点;
- 相机模块、默认相机模式、shake、cutscene、spectate 和锁定系统;
- `CameraType = Scriptable``BindToRenderStep``RenderStepped` 的既有用法;
- 相机优先级、所有权仲裁和恢复入口。
### Input
- 项目已有 input service、controls、action binding 和移动端按钮;
- 禁用 / 恢复移动、视角和动作的现有接口;
- skip UI、键盘、触控和手柄输入模式
- 输入绑定的所有者与清理方式。
### Data 与触发
- 配置格式、目标注册表、CollectionService tag、Attribute 或项目 actor ID
- 远端事件和服务器触发验证;
- 角色死亡、重生、回合结束和传送信号;
- lint、format、test 和 playtest 流程。
输出:
```text
Camera 能力:
Input 能力:
目标解析:
现有配置:
所有权 / 生命周期:
待确认项:
```
如果已有相机框架,扩展它;不要并行创建第二个全局相机所有者。
## 2. 事件设计
每个事件至少定义:
- `id``version`、触发条件和总时长;
- 镜头目标、shot list、节奏和关键情绪
- primary target 与按顺序尝试的 fallback
- `blendIn``blendOut`、FOV、shake 和平台 override
- 玩家控制策略;
- skip 是否允许、何时显示、由谁绑定;
- 重复触发、取消、超时和失败行为;
- comfort budget 与性能预算。
## 3. 实现边界
客户端播放器建议暴露:
```text
Play(event, context) -> success, reason?
Skip(reason)
Stop(reason)
Restore(reason)
IsPlaying() -> boolean
```
- `context` 注入项目已有目标解析、输入锁定、skip 绑定和日志适配器。
- 只在播放期间切换到 `Enum.CameraType.Scriptable`
- 使用项目一致的 render priority 更新最终 CFrame 和 FOV。
- shot 计算与 gameplay 状态分离。
- repeated trigger 按配置选择 ignore、replace 或 queue不隐式叠加。
- 目标解析失败时按 fallback 顺序处理;全部失败则安全退出,不占有相机。
## 4. Skip 与 Restore
- skip 输入适配键盘、触控、手柄和本地化提示。
- 只有当前播放 token 可以结束当前事件,防止旧回调恢复新镜头。
- Restore 必须幂等,可被多条结束路径重复调用。
- 恢复顺序与项目现有相机 / 输入系统协调;不能把其他系统刚取得的所有权覆盖掉。
- 如果相机实例在重生期间被替换,按项目所有权系统重新获取或直接交还默认系统。
## 5. Comfort QA
重点检查:
- 手机 shake 幅度和频率;
- 快速 yaw / pitch、roll 和反向运动;
- FOV 变化幅度与速度;
- 目标穿模、近裁剪和遮挡;
- 低帧率下轨迹跳跃;
- skip 提示是否足够早;
- 玩家主动镜头设置与减少动效偏好;
- 连续触发是否导致眩晕累积。
## 6. Studio MCP 可选验证
Roblox Studio MCP 只是可用时的可选通道:
1. 先确认当前环境实际提供可用 Studio 能力;
2. 从实际工具目录选择场景检查、Play、输出或属性读取方式
3. 不硬编码未知工具名,不把 Studio 集成作为唯一实现前提;
4. 不可用时,列出人工需检查的实例、触发步骤和预期结果。
## 交付
- 探测结论;
- CameraEvent 与 shot 说明;
- 目标解析、fallback、skip 和恢复路径;
- 修改文件;
- 自动检查与 Studio / 人工 playtest
- comfort 风险和剩余限制。

View File

@@ -0,0 +1,389 @@
# CameraEvent 工作流
## 1. 事件意图
实现前确认:
```yaml
event_id: boss_intro_01
trigger: enter_boss_arena
duration: 4.2
camera_goal: show_scale_and_threat
player_control: disabled
skip_allowed: true
target_priority:
- boss_head
- boss_root
comfort:
motion_sickness: low
mobile_shake_scale: 0.35
allow_roll: false
```
常见映射:
| 事件 | 目标 | 常见镜头语言 |
| --- | --- | --- |
| Boss 登场 | 威胁与尺度 | 低机位、慢推、轻 shake |
| 稀有掉落 | 聚焦奖励 | 短拉近、小角度 orbit |
| 终极技能 | 力量与冲击 | 短 FOV ramp、跟随、短停顿 |
| 胜利 | 成就感 | 缓慢 orbit、拉远展示场景 |
| 剧情提示 | 引导注意力 | 平滑 pan、目标锁定、反应镜头 |
## 2. CameraEvent 配置
优先沿用项目既有格式。ModuleScript 示例:
```lua
return {
id = "boss_intro_01",
version = 1,
duration = 4.2,
blendIn = 0.3,
blendOut = 0.4,
repeatedTrigger = "ignore", -- "ignore" | "replace" | "queue"
playerControl = "disabled",
skipAllowed = true,
targets = {
primary = "boss_head",
fallbacks = { "boss_root", "boss_model" },
},
shots = {
{
id = "reveal",
startTime = 0,
duration = 1.4,
type = "locked",
target = "boss_head",
positionOffset = Vector3.new(-5, 2, -8),
focusOffset = Vector3.new(0, 1, 0),
fov = 42,
},
{
id = "push",
startTime = 1.4,
duration = 2.8,
type = "dolly",
target = "boss_head",
fromOffset = Vector3.new(-5, 2, -8),
toOffset = Vector3.new(-3, 2.2, -5),
fovFrom = 42,
fovTo = 36,
shake = {
amplitude = 0.08,
frequency = 6,
},
},
},
platformOverrides = {
mobile = {
shakeScale = 0.35,
maxFovDelta = 5,
allowRoll = false,
},
},
}
```
如果配置要导出 JSON / YAML`Vector3``CFrame` 和 Enum 转为数字或字符串结构,并在边界层解析;不要在可序列化配置中保存 live Instance。
## 3. Shot 类型
- `locked`:固定 offset 看向目标。
- `dolly`:位置平滑推近或拉远。
- `orbit`:围绕目标小角度旋转;手机限制角速度。
- `spline`:多控制点路径;缓存采样结果。
- `follow`:跟随移动目标;位置与注视点分别平滑。
- `fov_ramp`:短时改变 FOV。
- `shake`:叠加到最终 CFrame不能影响基础恢复。
- `reaction`:切到玩家或 NPC 的反应。
每种 shot 都要定义目标失效后的行为fallback、保持最后姿态或取消事件。
## 4. 最小 Play / Skip / Restore 示例
这是项目无现成播放器时的最小参考,不是固定目录或公共 API。`resolveTarget`、输入锁定与 skip 绑定由探测到的项目能力注入。
```lua
--!strict
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local CameraPlayer = {}
local UPDATE_KEY = "CameraEventPlayer"
type Context = {
resolveTarget: (targetId: string) -> Instance?,
setControlsEnabled: ((enabled: boolean) -> ())?,
bindSkip: ((callback: () -> ()) -> (() -> ())?)?,
}
type Snapshot = {
camera: Camera,
cameraType: Enum.CameraType,
cameraSubject: Instance?,
cframe: CFrame,
fieldOfView: number,
}
local active: {
token: {},
snapshot: Snapshot,
unbindSkip: (() -> ())?,
controlsChanged: boolean,
context: Context,
}? = nil
local function targetPosition(target: Instance): Vector3?
if target.Parent == nil then
return nil
end
if target:IsA("BasePart") then
return target.Position
end
if target:IsA("Model") then
return target:GetPivot().Position
end
return nil
end
local function resolveWithFallbacks(event, context: Context): Instance?
local ids = { event.targets.primary }
for _, id in event.targets.fallbacks or {} do
table.insert(ids, id)
end
for _, id in ids do
local target = context.resolveTarget(id)
if target and targetPosition(target) then
return target
end
end
return nil
end
function CameraPlayer.Restore(reason: string)
local state = active
if state == nil then
return
end
active = nil
RunService:UnbindFromRenderStep(UPDATE_KEY)
if state.unbindSkip then
local ok, failure = pcall(state.unbindSkip)
if not ok then
warn("CameraEvent skip cleanup failed:", failure)
end
end
local camera = state.snapshot.camera
if camera.Parent then
local ok, failure = pcall(function()
camera.CFrame = state.snapshot.cframe
camera.FieldOfView = state.snapshot.fieldOfView
local subject = state.snapshot.cameraSubject
if subject and subject.Parent then
camera.CameraSubject = subject
end
camera.CameraType = state.snapshot.cameraType
end)
if not ok then
warn("CameraEvent camera restore failed:", failure)
end
end
if state.controlsChanged and state.context.setControlsEnabled then
local ok, failure = pcall(state.context.setControlsEnabled, true)
if not ok then
warn("CameraEvent controls restore failed:", failure)
end
end
print("CameraEvent restored:", reason)
end
function CameraPlayer.Skip(reason: string?)
CameraPlayer.Restore(reason or "player_skip")
end
function CameraPlayer.Play(event, context: Context): (boolean, string?)
local shot = event.shots and event.shots[1]
if shot == nil then
return false, "missing_shot"
end
local duration = shot.duration or event.duration
if type(duration) ~= "number" or duration <= 0 then
return false, "invalid_duration"
end
if active then
if event.repeatedTrigger == "ignore" then
return false, "already_playing"
end
CameraPlayer.Restore("replaced")
end
local camera = workspace.CurrentCamera
if camera == nil then
return false, "camera_missing"
end
local target = resolveWithFallbacks(event, context)
if target == nil then
return false, "target_and_fallbacks_missing"
end
local snapshot: Snapshot = {
camera = camera,
cameraType = camera.CameraType,
cameraSubject = camera.CameraSubject,
cframe = camera.CFrame,
fieldOfView = camera.FieldOfView,
}
local token = {}
active = {
token = token,
snapshot = snapshot,
unbindSkip = nil,
controlsChanged = context.setControlsEnabled ~= nil,
context = context,
}
if context.setControlsEnabled then
local ok, failure = pcall(context.setControlsEnabled, false)
if not ok then
CameraPlayer.Restore("controls_lock_failed")
return false, "controls_lock_failed: " .. tostring(failure)
end
end
local state = active
local bindSkip = context.bindSkip
if event.skipAllowed and bindSkip and state then
local ok, cleanupOrFailure = pcall(bindSkip, function()
if active and active.token == token then
CameraPlayer.Skip()
end
end)
if not ok then
CameraPlayer.Restore("skip_bind_failed")
return false, "skip_bind_failed: " .. tostring(cleanupOrFailure)
end
if active and active.token == token then
state.unbindSkip = cleanupOrFailure
else
if type(cleanupOrFailure) == "function" then
pcall(cleanupOrFailure)
end
return false, "skip_during_bind"
end
end
local tookCamera, cameraFailure = pcall(function()
camera.CameraType = Enum.CameraType.Scriptable
end)
if not tookCamera then
CameraPlayer.Restore("camera_takeover_failed")
return false, "camera_takeover_failed: " .. tostring(cameraFailure)
end
local startedAt = os.clock()
local startCFrame = camera.CFrame
local startFov = camera.FieldOfView
local offset = shot.positionOffset or shot.toOffset or Vector3.new(0, 2, -8)
local focusOffset = shot.focusOffset or Vector3.zero
local targetFov = shot.fov or shot.fovTo or startFov
RunService:BindToRenderStep(UPDATE_KEY, Enum.RenderPriority.Camera.Value + 1, function()
if active == nil or active.token ~= token then
return
end
local ok, failure = pcall(function()
local position = targetPosition(target)
if position == nil then
CameraPlayer.Restore("target_lost")
return
end
local elapsed = os.clock() - startedAt
local alpha = math.clamp(elapsed / duration, 0, 1)
local eased = TweenService:GetValue(
alpha,
Enum.EasingStyle.Quad,
Enum.EasingDirection.InOut
)
local goal = CFrame.lookAt(position + offset, position + focusOffset)
camera.CFrame = startCFrame:Lerp(goal, eased)
camera.FieldOfView = startFov + (targetFov - startFov) * eased
if alpha >= 1 then
CameraPlayer.Restore("completed")
end
end)
if not ok then
warn("CameraEvent failed:", failure)
CameraPlayer.Restore("runtime_error")
end
end)
return true
end
function CameraPlayer.IsPlaying(): boolean
return active ~= nil
end
return CameraPlayer
```
该最小示例只播放 `shots[1]`用于验证数据、所有权、skip、fallback 和 restore 契约。生产实现还应加入完整 shot timeline、blendOut、角色重生和相机所有权仲裁。
如果项目没有输入抽象,可在项目设计确认后使用 Roblox 原生输入 API 建立最小 skip 适配器;绑定名、按键、触控按钮、手柄键和清理由当前项目决定,不在相机核心中硬编码。
## 5. Restore 设计
Restore 必须:
1. 先使 active token 失效;
2. 解绑 render update
3. 解绑 skip UI / input
4. 恢复相机快照或交还项目相机系统;
5. 恢复本次事件确实禁用的控制;
6. 清理当前 event 状态;
7. 记录结束原因。
多个结束路径重复调用 Restore 应安全无副作用。
## 6. Preview 与调参
预览工具应与 runtime 隔离,并支持:
- 选择 event ID
- play、pause、restart、逐帧
- shot 边界、FOV 和 shake 曲线;
- target / fallback 状态;
- 路径采样、遮挡和速度警告;
- 导出到项目配置格式。
Studio MCP 可用时按实际工具目录辅助检查;不可用时使用普通 Play 流程。不要硬编码未知工具名,也不要把预览插件代码混入发布路径。
调参默认只改配置,并报告每项 timing、offset、FOV、shake、blend 的理由。
## 7. QA
- 正常触发只开始一次;
- repeated trigger 符合 ignore / replace / queue
- primary 缺失会使用 fallback
- 所有目标缺失时不占有相机;
- skip 在键盘、触控和手柄路径可用;
- 正常结束、skip、错误、死亡、重生、回合结束均恢复
- 相机类型、Subject、CFrame、FOV 和输入恢复;
- 低帧率不会越过结束或遗留 render binding
- 手机 override 降低 shake、FOV 和旋转;
- 多客户端只收到各自应有的表现;
- 日志包含 event ID、结束原因和缺失目标。
验收标准:任何结束路径都不能让相机停留在 `Scriptable`,不能让玩家控制永久禁用,也不能让旧事件的回调覆盖新事件。

View File

@@ -0,0 +1,43 @@
---
name: roblox-commonkit-usage
description: 在 Roblox/Luau 仓库中复用已实际存在的 CommonKit。仅当目标仓库检测到真实 CommonKit 目录、包声明、Rojo 映射、require 用法或导出时触发;用于查找可复用模块、判断运行时归属、迁移通用工具和避免重复实现。未检测到 CommonKit 时不得触发或假定其存在。
---
# Roblox CommonKit 使用
## 触发门槛
开始前必须在目标仓库确认至少一项证据:
- 实际存在的 CommonKit 目录或包;
- 依赖清单、锁文件或 Rojo 映射声明 CommonKit
- 现有源码 require CommonKit
- CommonKit 的入口或导出表。
只有名称相似、用户泛泛要求“复用工具”或模板提到 CommonKit不算证据。未检测到证据时停止使用本 Skill改按目标项目现有工具模块处理不创建一个新的 CommonKit。
## 工作流程
1. 读取项目映射、包声明和 CommonKit 入口,确认源码位置与运行时位置。
2. 以目标仓库实际导出、类型和调用示例建立模块 catalogreference 中的类别只用于检索,不代表模块必然存在。
3. 检查要解决的能力是否已有实现,核对 API、运行时边界、生命周期和调用约定后再复用。
4. Shared 模块只能使用双方可安全访问的 APIServer 和 Client 模块保持各自运行时边界。
5. CommonKit 只承载跨功能通用机制。游戏规则、Remote 协议、经济配置、资产 ID、UI 文案和领域语义留在项目内。
6. 缺少 helper 时,只有它已表现出跨功能复用价值才考虑扩展 CommonKit单一功能逻辑留在功能模块。
7. 修改后使用仓库已有验证入口检查 CommonKit 和所有受影响调用方。
## 导入与配置
- 根据项目实际 Rojo 映射和导出方式 require不假定固定源码或运行时路径。
- 优先使用公开入口,不绕过 package API 直接引用内部文件。
- CommonKit 不直接 require 项目专用配置;通过 `Init(config)``SetConfig(config)` 或函数参数注入。
- 本地事件总线不能代替客户端/服务端 Remote。
- 传输、序列化 helper 不能成为项目 Remote 协议的唯一来源;协议仍由项目拥有。
## 依赖
若目标仓库没有 CommonKit不得为了使用本 Skill而下载或安装它。任何新增或升级都必须遵循 `dependency-governance`,说明来源、许可、维护状态、影响和现有替代,并先获得用户确认。
## Reference
需要模块检索类别、归属判断或迁移检查时读取 [commonkit.md](references/commonkit.md),但最终 catalog 始终以目标仓库为准。

View File

@@ -0,0 +1,80 @@
# CommonKit 参考
本文件只提供检索与判断框架。模块名、路径、API、类型和导出必须从目标仓库实际内容确认。
## 建立目标仓库 catalog
1. 读取包入口、导出表、类型声明与 Rojo 映射。
2. 搜索现有 require 和调用示例。
3. 为每个实际模块记录:
- 公开导出与类型
- Shared / Server / Client 归属
- 初始化或配置要求
- 生命周期与清理要求
- 已有调用方
4. 不把本文示例类别写成仓库已存在模块。
## 常见检索类别
### Shared 候选
- 参数与 Roblox 类型验证
- 日志封装
- Instance 查找、属性和销毁 helper
- 序列化与负载规范化
- 同一 VM 内的本地事件
- 纯函数与无特权数据转换
### Server 候选
- DataStore 序列化、重试与保存 helper
- 玩家数据生命周期脚手架
- 服务端拥有的 NPC 移动通用机制
通用 helper 不应直接包含项目经济、库存、战斗或进度规则。
### Client 候选
- 无主题依赖的 UI 原子 helper
- 本地音频、动画和 VFX 运行时
- 通用输入、布局或补间 helper
项目主题、文案、资产配置和复合 UI 仍由项目模块拥有。
## 归属判断
- 只有双方都可安全执行且不依赖特权服务的模块才能归入 Shared。
- 使用 DataStore、玩家持久化生命周期或权威模拟的模块归入 Server。
- 使用本地 UI、输入、相机和纯表现 API 的模块归入 Client。
- 知道游戏 Remote 名称、商品 ID、玩法规则、服务启动顺序或 UI 文案的代码留在项目内。
## 两层 UI
- CommonKit 层只提供无主题、无文案、无项目配置的原子操作。
- 项目层组合主题、字体、颜色、文案和产品规则。
- 判断重复 helper 时比较契约和实现,不只比较函数名。
## 网络
CommonKit 可以提供通用序列化、验证或传输 helper但项目仍拥有 Remote 名称、类型、验证器、限流和权限规则。不得用本地事件机制替代跨网络通信。
## 配置注入
通用模块通过明确参数或初始化 API 接收项目配置,不直接依赖项目目录。例如:
```lua
AudioRuntime.Init(audioConfig)
EffectsRuntime.SetAssetsFolder(effectsFolder)
```
实际 API 以目标仓库导出为准。
## 迁移检查
1. 移除项目专用目录、Remote、配置和资产假设。
2. 将项目配置改为显式注入。
3. 保持 Shared / Server / Client 边界。
4. 遵循目标仓库文件名、类型和严格模式约定。
5. 保持公开 API或先检查并迁移全部调用方。
6. 使用仓库现有验证入口检查 CommonKit 及调用方。
7. 如果需要新增外部依赖,遵循 `dependency-governance` 并先取得确认。

View File

@@ -0,0 +1,48 @@
---
name: roblox-project-initializer
description: 为新的或早期 Roblox 项目制定初始化与架构规划。用户要求规划项目结构、评审初始架构、选择 Luau/roblox-ts、Rojo 工作流、数据与网络边界或初始化检查清单时使用;只输出规划,不自动创建脚手架。
---
# Roblox 项目初始化规划
本 Skill 只负责形成可确认的项目规划,不自动创建目录、源码、配置,不安装工具或依赖。用户确认规划后,将实现工作交给切片实现任务。
## 工作流程
1. 先读取目标仓库已有 README、架构文档、项目映射、工具配置和目录结构已有项目约定优先于模板。
2. 收集玩法类型、团队规模、语言、单/多 Place、持久化、经济与付费、网络频率、UI、目标平台和发布方式。
3. 信息足够时直接判断;只有缺失信息会实质改变架构时,最多提出三个澄清问题。
4. 将项目归为轻量原型、标准项目或大型 LiveOps 项目,说明判断依据。
5. 给出最小可行的架构、工具链、目录边界、数据和安全约定。
6. 将工具和库分为“当前必需”“满足条件后再加”“项目初期避免”。
7. 输出分阶段初始化清单、主要风险与待确认项,等待用户确认后再进入实现。
## 必守边界
- 客户端只提交意图,服务端裁定游戏结果、经济、奖励、战斗、交易和购买履约。
- Remote 必须有集中契约、参数验证、权限检查和必要的限流。
- 持久化项目要规划 schema 版本、迁移、会话锁、失败处理、保存策略与环境隔离。
- 奖励、回执和交易流程需要幂等或防重放设计。
- `Shared``Server``Client` 与服务端专用资产保持清晰边界。
- 不把建议写成已执行结果,不声称已创建脚手架。
## 依赖治理
- 先检查仓库是否已有可复用工具、框架、包管理和内部模块。
- 任何新增、下载、安装或升级建议都必须列出名称、用途、目标版本或版本选择依据、来源、许可、维护状态、影响范围与现有替代方案。
- 在用户明确确认前,不执行安装,不写入依赖或锁文件。
- 优先无新增依赖方案;避免功能重叠、维护状态不明或许可不清的包。
## References
- 目录与运行时边界:[architecture.md](references/architecture.md)
- 数据与安全:[data-and-security.md](references/data-and-security.md)
- 工具与依赖决策:[tools-and-libraries.md](references/tools-and-libraries.md)
- 输出格式:[output-templates.md](references/output-templates.md)
- 场景校准:[examples.md](references/examples.md)
只读取当前任务需要的 reference。
## 输出要求
输出至少包含:项目判断、已发现的现有能力、推荐架构、目录草案、必须约定、依赖决策、分阶段清单、风险和待确认项。对每项推荐简述理由与取舍。

View File

@@ -0,0 +1,86 @@
# 架构参考
用于规划仓库布局、运行时边界、Place 拓扑和 Service / Controller 职责。目标仓库已有结构和映射优先。
## 运行时边界
### Shared
只放客户端与服务端都确实需要且可安全读取的内容:
- 类型、常量和配置
- 纯工具函数
- Remote 协议与序列化
- 不包含服务端秘密的共享逻辑
不要把 Shared 当成所有模块的默认存放处。
### Server
负责持久化、经济、购买、奖励、战斗结算、匹配、管理命令和反滥用。敏感规则与服务端专用资产不能复制给客户端。
### Client
负责 UI、输入、相机、动画、特效、本地表现和必要的预测不决定权威结果。
## 复杂度层级
### 轻量原型
- 目标是快速验证核心玩法。
- 使用简单明确的 Shared / Server / Client 边界。
- 只有现有工作流确实需要时才加入同步、包管理或框架。
- 不为未来可能的规模提前建立重型抽象。
### 标准项目
- 适用于团队协作、持久化或商业化项目。
- 规划固定启动入口、按领域拆分的服务和控制器、集中网络契约、数据边界、测试与发布检查。
- 工具与目录名称遵循目标仓库现有约定,不强制套用模板。
参考形状:
```text
src/
ReplicatedStorage/
Shared/
Config/
Types/
Network/
Util/
ServerScriptService/
Server/
Bootstrap.server.luau
Services/
Data/
StarterPlayer/
StarterPlayerScripts/
Client/
Bootstrap.client.luau
Controllers/
UI/
ServerStorage/
ServerAssets/
tests/
```
### 大型 LiveOps 项目
在标准项目基础上按真实需要增加:
- 多 Place 启动与共享契约
- 环境配置和发布控制
- 数据迁移与回滚策略
- 分析、遥测和 LiveOps 边界
- smoke test、部署检查与 UI 预览
## 单 Place 与多 Place
核心循环完整、团队较小或仍在验证阶段时优先单 Place。只有大厅/对局、任务实例、交易中心或教程等运行时角色已经真实分离时才采用多 Place。
## Service / Controller
- Service 持有服务端权威状态和领域规则。
- Controller 持有客户端输入、展示和本地编排。
- Bootstrap 只装配依赖和生命周期,不承载功能逻辑。
- 避免巨型管理器、循环依赖、客户端引用服务端模块,以及把业务逻辑写进 UI。

View File

@@ -0,0 +1,66 @@
# 数据与安全参考
项目涉及持久化、购买、奖励、战斗、交易或客户端请求时读取。
## 核心原则
客户端不可信:客户端报告意图,服务端验证条件并决定结果。
## Server Authority 清单
- 验证每个 Remote 的参数形状、身份、权限与所有权。
- 货币、物品、奖励、购买履约、掉落、战斗结果和交易均由服务端处理。
- 校验服务端状态、目标、距离、冷却与速率。
- 不信任客户端提交的价格、数量、库存、伤害或目标标识。
- 对敏感或高频请求限流,对持续异常输入按项目策略记录。
## 数据层清单
持久化规划至少包含:
- `SchemaVersion`
- 迁移策略
- 会话锁
- 自动保存和离开保存
- 加载失败与损坏数据处理
- 安全停服保存
- 开发、测试、生产环境隔离
- 错误发布的回滚思路
示意数据结构:
```lua
local ProfileTemplate = {
SchemaVersion = 1,
Currencies = {},
Inventory = {},
Stats = {},
Flags = {},
}
```
数据必须经过专用服务访问;业务模块不直接任意修改原始 profile。
## 购买、奖励与交易
- 回执重试不得重复发奖。
- 奖励使用稳定的幂等键或等效防重机制。
- 交易从服务端视角原子完成,并防止重复扣除、重复发放。
- 客户端提供的商品或物品 ID 必须对照服务端配置验证。
- 记录影响经济的原因和必要审计信息。
## 管理与调试
- 命令实现位于服务端。
- 每条命令都执行服务端权限检查。
- 开发、测试和生产环境采用不同限制。
- 发奖、货币、库存、传送、审核和 LiveOps 操作需要更严格授权与日志。
- 客户端命令入口是否可见不能替代授权。
## 评审优先级
1. 客户端能否直接改变经济或进度结果?
2. 同一奖励、回执或交易能否处理两次?
3. 是否存在清晰的数据模板、版本和迁移策略?
4. Remote 是否验证并限流?
5. 本地表现与权威状态是否分离?

View File

@@ -0,0 +1,38 @@
# 场景示例
示例用于校准判断,不应机械复制;目标仓库现有能力优先。
## 单人玩法原型
目标是在短周期内验证核心循环:
- 归类为轻量原型。
- 保留基本的 Shared / Server / Client 边界。
- 复用开发者已有工作流,不为尚未出现的需求引入包管理或重型框架。
- 输出最短可执行规划,不自动创建脚手架。
## 带经济和交易的养成项目
- 归类为标准项目高端或大型 LiveOps。
- 优先规划服务端权威经济、专用数据服务、schema 迁移和环境隔离。
- 购买回执、奖励与交易必须幂等并可审计。
- 工具和库只作为候选;先检查现有方案,再按依赖治理要求取得确认。
## 高频多人战斗
- 强调客户端预测与服务端验证的边界。
- 集中定义 Remote 类型、验证器和限流。
- 先测量网络频率和负载,再决定是否需要额外网络库。
- 对战斗结算、排行榜和奖励设计关键路径测试。
## 现有仓库评审
- 先读取映射、入口、目录和已有工具。
- 检查 Shared / Server / Client 边界、网络契约和数据写入路径。
- 优先建议少量高价值修正,不把现有仓库强行迁移到模板结构。
## roblox-ts 团队
- 语言选择不改变运行时信任边界。
- 复用仓库现有 TypeScript、构建和包管理方案。
- 仅当团队明确需要且确认依赖影响时建议框架。

View File

@@ -0,0 +1,84 @@
# 输出模板
## 完整初始化规划
```markdown
# Roblox 项目初始化规划:[项目名]
## 1. 项目判断
- 类型与阶段:
- 复杂度:
- 语言:
- Place 拓扑:
- 主要风险:
## 2. 已有能力
- 已有架构与目录:
- 已有工具:
- 可复用模块:
## 3. 推荐架构
- Shared
- Server
- Client
- 数据:
- 网络:
## 4. 目录草案
[仅作为规划,不创建文件]
## 5. 依赖决策
### 当前必需
### 满足条件后再加
### 项目初期避免
每个新增候选注明用途、版本依据、来源、许可、维护状态、影响、现有替代和“待用户确认”。
## 6. 分阶段清单
1. ...
## 7. 风险与待确认项
- ...
## 8. 规划确认后的实现切片
- ...
```
## 快速建议
```markdown
## 建议
- 决策:
- 已有能力:
- 理由:
- 当前采用:
- 延后:
- 是否涉及新增依赖:
- 主要风险:
```
## 仓库评审
```markdown
## 现状判断
- ...
## 值得保留
- ...
## 优先修正
1. ...
## 目标边界
- Shared
- Server
- Client
## 依赖影响
- 无新增依赖 / 待确认候选:
## 后续实现切片
1. ...
```
所有模板均为规划输出,不表示已经创建目录、修改配置或安装依赖。

View File

@@ -0,0 +1,56 @@
# 工具与依赖决策参考
这里列出的名称只是评估候选,不是默认依赖,也不授权安装。
## 决策顺序
1. 读取目标仓库的 README、工具版本文件、依赖清单、锁文件、验证脚本和现有模块。
2. 判断现有能力能否满足目标,优先复用。
3. 只有存在明确缺口时才提出外部候选。
4. 对每个候选说明名称、用途、目标版本或版本选择依据、来源、许可、维护状态、影响范围和现有替代。
5. 获得用户明确确认后,才可在后续实现任务中下载、安装、升级或修改依赖文件。
## 常见能力类别
- 文件同步Rojo
- 包管理Wally
- 工具版本固定:仓库现有版本管理方案
- 格式化StyLua
- 静态检查Selene、Luau 类型分析
- 测试:项目现有测试框架或 TestEZ
- 持久化:项目现有数据层或经确认的 profile 库
- 生命周期清理:项目现有清理器或经确认的通用库
- 管理命令:项目现有工具或经确认的服务端命令系统
不要因为候选常见就把它列为必装。原型可能只需要现有 Studio 工作流;成熟仓库也可能已有等价内部能力。
## 框架判断
- 项目小、团队已有模式或只需少量 Service / Controller 时,优先无新框架。
- 只有团队明确接受其约定、维护成本和迁移影响时才建议架构框架。
- ECS 只适合实体数量和组合式系统复杂度已证明需要的场景。
- roblox-ts 是团队与构建链决策,不应由模板默认切换。
## 网络与 UI
- 原生 Remote 加集中协议层通常足够;先测量流量再考虑额外网络库。
- 不因流行度引入响应式 UI 或状态框架;仅在 UI 或状态复杂度能证明收益时提出。
- 传输库不替代服务端验证、限流和权限边界。
## 推荐分组
### 当前必需
仅包含目标仓库缺失且当前里程碑确实无法绕开的能力;仍需用户确认外部依赖。
### 满足条件后再加
写明触发条件、收益与成本,不提前安装。
### 项目初期避免
- 功能重叠的包
- 许可或来源不清的代码
- 缺少维护迹象的关键依赖
- 为简单工具函数引入大型库
- 尚无真实边界的多 Place、ECS 或重型框架

View File

@@ -0,0 +1,153 @@
---
name: roblox-ui-producer
description: 设计、实现与验证 Roblox 页面、HUD、商店、背包、任务、设置和组件系统。遇到 UI、HUD、移动端适配、SafeArea、界面重构或 React-Lua 页面请求时使用;先探测项目能力,再通过 Framework Decision Gate 选择原生命令式、React-Lua 或混合路线。
---
# Roblox UI Producer
Roblox UI 的统一入口。主流程使用中文Roblox API、类型名和项目内专有名词保留英文。
## 不可跳过的约束
1. 先形成 `UI Design Brief`,用户确认后再实现;仅修复明确缺陷时可直接进入探测。
2. 实现前必须探测项目已有 UI、依赖、目录、配置、生命周期和输入模式。
3. `UIUtils``Controller` 架构和 `React-Lua` 只能在项目已存在对应能力时复用;项目没有时,必须先获得用户对新增依赖或新架构的明确确认。
4. 不因页面复杂就静默安装框架。未检测到可复用框架且用户未确认新增依赖时,使用 Roblox 原生 `Instance` API 或只提交设计方案。
5. 所有路线都必须覆盖 PC、手机、横竖屏、SafeArea、触控尺寸、生命周期清理和可重入行为。
6. 外部库、插件、字体或素材受项目依赖治理约束;确认前不得下载、安装或写入锁文件。
## 按需读取
设计阶段:
- [设计规范模板](docs/ui-design-spec-template.md)
- [组件模式](docs/ui-component-patterns.md)
- [动效 Token](docs/ui-animation-tokens.md)
- [平台约束](docs/ui-roblox-constraints.md)
仅当 Gate 允许 `React-Lua``Hybrid` 时读取:
- [框架与依赖选择](docs/react-lua/roblox-ui-libraries.md)
- [组件组合](docs/react-lua/react-lua-component-patterns.md)
- [自定义 Hooks](docs/react-lua/react-lua-custom-hooks.md)
- [状态管理](docs/react-lua/react-lua-state-management.md)
- [动效](docs/react-lua/react-lua-animation.md)
- [响应式布局](docs/react-lua/roblox-ui-responsive.md)
- [性能](docs/react-lua/react-lua-performance.md)
- [限制与替代方案](docs/react-lua/roblox-ui-limitations.md)
## 工作流
### 1. 明确需求
至少确认:
- 页面目标与主要用户任务;
- 目标平台、横竖屏与游戏手柄范围;
- 风格关键词、信息优先级和核心 CTA
- 组件、状态、空态、错误态和加载态;
- 数据来源、网络边界和是否允许新增依赖。
信息不足时最多集中追问两轮。
### 2. 设计与确认
产出 `UI Design Brief`
```text
目标与范围:
信息架构:
关键交互:
视觉 Token
组件与状态:
动效:
PC / 手机布局:
SafeArea 策略:
验收标准:
不改动范围:
```
确认前不写实现代码。
### 3. 探测项目能力
在仓库和已连接的项目结构中查找证据,不按固定路径猜测:
- 包管理配置、依赖目录以及 `require` 语句;
- `React``ReactRoblox`、Roact 或其他声明式 UI 痕迹;
- 原生 `Instance.new` 页面、页面路由和生命周期所有者;
- 项目是否已有名为 `UIUtils` 的工具,或采用 `Controller` 组织;
- Design System、Token、配置、输入、数据绑定和清理模式
- UI 源目录、测试、格式化和静态检查命令。
记录“已发现 / 未发现 / 尚待确认”,不要把示例模块名当成事实。
## Framework Decision Gate
实现前给出唯一结论:`Native Imperative``React-Lua``Hybrid`
按以下优先级决策:
1. 用户显式要求且依赖已存在:遵循用户要求。
2. 项目已有主栈:沿用主栈,除非用户要求迁移。
3. 用户要求项目未安装的框架:暂停引入,说明用途、版本、来源、许可、维护状态、影响范围和现有替代方案,等待确认。
4. 无框架证据且无新增依赖确认:选择 `Native Imperative`
5. `Hybrid` 只在项目同时具备两条路线或用户明确确认边界时使用,并写清状态来源、声明式内容层与命令式桥接层。
Gate 输出:
```text
结论:
项目证据:
依赖状态:
选择理由:
复用能力:
禁止假设:
```
## 实现要求
### 通用
- 视觉参数集中为 Token避免散落魔术数字。
- 数据状态与展示分离;客户端 UI 不承担服务器权威逻辑。
- 事件连接、Tween、延时任务、根节点和输入绑定都有唯一所有者。
- `Open` / `Close` / `Destroy` 或对应生命周期必须幂等;重复打开、快速关闭、角色重生均不泄漏。
- 使用 `Activated` 处理跨输入按钮;装饰子节点不得意外拦截输入。
- 高频视觉值避免触发不必要的整树更新,先测量再优化。
### 移动端与 SafeArea
- 面板宽高按 viewport 或父容器夹取,不复用桌面固定尺寸。
- 可交互区域建议至少 `44×44` 像素,并留出触控间距。
- 选择一种 SafeArea 策略:引擎 `ScreenInsets` 或项目已有手动 inset不得重复叠加。
- 监听 `CurrentCamera.ViewportSize`、相关容器 `AbsoluteSize` 或项目现有响应式信号;变化后重新计算布局。
- 手机可降低列数、字号上限、动效密度和装饰成本,但不得牺牲可读性与主要操作。
### 路线附加要求
- `Native Imperative`:默认只依赖 Roblox 原生 API若探测到现有 `UIUtils``Controller` 体系,再按其契约复用。
- `React-Lua`仅在已安装或用户确认引入后使用根挂载、Hooks、卸载和依赖路径遵循项目现状。
- `Hybrid`:明确谁拥有状态、谁创建实例、谁负责清理;命令式桥接不得绕开声明式状态树。
## 验证
至少检查:
- 桌面、手机横屏、手机竖屏和极端窗口尺寸;
- SafeArea、滚动、文本溢出、本地化长文本和触控可达性
- 加载、空、错误、禁用、重复点击和快速开关;
- 重生、页面销毁、输入恢复、连接与 Tween 清理;
- 性能热点、长列表和高频更新;
- 项目既有测试、格式化和静态检查。
Roblox Studio 集成仅是可选验证通道:先确认当前环境确实提供可用的 Studio 能力,再从实际工具目录选择检查方式。不可硬编码未发现的工具名;不可用时列出需要人工在 Studio 完成的检查。
## 交付格式
- Gate 结论与证据;
- 修改文件及职责;
- 依赖变化(没有则明确写“无新增依赖”);
- 生命周期、移动端和 SafeArea 处理;
- 已执行验证与未执行原因;
- 剩余风险。

View File

@@ -0,0 +1,128 @@
# React-Lua 动效
仅在项目已具备 `React-Lua` / `ReactRoblox` 或用户确认依赖后使用。默认使用 Roblox 原生 `TweenService` 与 Binding第三方 spring 库必须再次通过项目探测或依赖确认。
## 选择
| 需求 | 路线 |
| --- | --- |
| 简单 Scale、Position、Color 过渡 | Binding + `TweenService` |
| 每帧派生的轻量视觉值 | Binding |
| 多值组合 | `React.joinBindings` |
| 进入 / 退出 | 显式 mount 状态机 |
| spring 手感 | 仅复用项目已安装且兼容的库 |
高频值不用 `useState` 每帧更新,因为结构性重渲染没有必要。
## useTweenBinding
```lua
--!strict
local TweenService = game:GetService("TweenService")
type TweenConfig = {
duration: number,
style: Enum.EasingStyle,
direction: Enum.EasingDirection,
}
local function useTweenBinding(target: number, config: TweenConfig): React.Binding<number>
local binding, setBinding = React.useBinding(target)
local value = React.useMemo(function()
return Instance.new("NumberValue")
end, {})
React.useEffect(function()
value.Value = binding:getValue()
local changed = value:GetPropertyChangedSignal("Value"):Connect(function()
setBinding(value.Value)
end)
local tween = TweenService:Create(value, TweenInfo.new(
config.duration,
config.style,
config.direction
), { Value = target })
tween:Play()
return function()
tween:Cancel()
changed:Disconnect()
end
end, { target, config.duration, config.style, config.direction } :: { any })
React.useEffect(function()
return function()
value:Destroy()
end
end, { value } :: { any })
return binding
end
```
项目已有动画 Hook 时优先复用并遵循其取消契约。
## 进入 / 退出状态
```lua
type Phase = "hidden" | "entering" | "visible" | "leaving"
local function useAnimatedMount(visible: boolean, exitDuration: number): (boolean, Phase)
local mounted, setMounted = React.useState(visible)
local phase, setPhase = React.useState(if visible then "visible" else "hidden")
React.useEffect(function()
if visible then
setMounted(true)
setPhase("entering")
local pending = task.defer(function()
setPhase("visible")
end)
return function()
task.cancel(pending)
end
end
setPhase("leaving")
local pending = task.delay(exitDuration, function()
setPhase("hidden")
setMounted(false)
end)
return function()
task.cancel(pending)
end
end, { visible, exitDuration } :: { any })
return mounted, phase
end
```
可见状态快速反转时cleanup 会取消旧 thread。业务销毁不得等待退出动效完成。
## 第三方 spring
只有同时满足以下条件才使用:
- 项目包声明已存在兼容版本,或用户已确认引入;
- 当前项目已有 import 和测试范例;
- 使用场景确实需要可中断的物理手感;
- 移动端成本经过验证。
不要在文档或代码中假定固定包名、固定路径或默认配置。
## 性能与舒适度
- Binding 的 map 函数保持纯净和轻量。
- 页面隐藏后停止循环更新。
- 多个属性共享同一进度值,避免为每个属性建立 RenderStep。
- 大面积透明、模糊替代层和大量 `CanvasGroup` 先在手机测量。
- 尊重项目“减少动效”设置;没有设置时仍避免闪烁、快速旋转和过度弹跳。
## 自查
- cleanup 取消 Tween、连接和 task
- unmount 后不再调用 setter
- 外部动画依赖已存在或已确认;
- 快速开关不会卡在半透明或错误 Scale
- 低帧率下最终状态收敛;
- 手机端动效不会遮挡或延迟主要操作。

View File

@@ -0,0 +1,123 @@
# React-Lua 组件组合模式
仅在项目已具备 `React-Lua` / `ReactRoblox` 或用户确认依赖后使用。所有 import、Token 和根挂载路径都从项目探测结果获取。
## 选择指南
| 模式 | 适用 | 避免 |
| --- | --- | --- |
| 普通 props | 简单父子组件 | props 已明显穿透多层 |
| Compound Components | Tabs、Accordion 等共享隐式状态 | 单个 `activeId` prop 已足够 |
| Slots | Header / Body / Footer 等固定区域 | 槽位过多导致接口模糊 |
| Context | 低频树级主题或页面上下文 | 高频计时、位置和动画 |
| Portal | Tooltip、Toast、Modal 需要独立层级 | 调整当前层级即可解决 |
| Hooks | 复用行为和生命周期 | 纯视觉静态组件 |
| forwardRef | 必须访问底层 Instance | callback prop 可以完成目标 |
| Fragment | 返回多个兄弟节点 | 实际需要容器做布局或裁剪 |
## Compound Components
根组件拥有状态,子组件通过 Context 消费。Context value 使用 `useMemo` 保持引用稳定。
```lua
local TabsContext = React.createContext(nil)
local function TabsRoot(props)
local activeId, setActiveId = React.useState(props.defaultId)
local value = React.useMemo(function()
return {
activeId = activeId,
setActiveId = setActiveId,
}
end, { activeId } :: { any })
return React.createElement(TabsContext.Provider, {
value = value,
}, props.children)
end
```
子项必须有稳定、数据派生的 key不要用可变数组索引标识会重排的数据。
## Slots
固定结构由容器控制,调用方传入内容:
```lua
type CardProps = {
header: React.ReactElement?,
body: React.ReactElement?,
footer: React.ReactElement?,
}
local function Card(props: CardProps)
return React.createElement("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundColor3 = tokens.colors.surface,
}, {
Header = props.header,
Body = props.body,
Footer = props.footer,
Corner = React.createElement("UICorner", {
CornerRadius = tokens.radius.md,
}),
})
end
```
`tokens` 必须来自项目现有 Design System 或当前功能域配置,不假定固定模块路径。
## Portal
Portal 适用于需要脱离裁剪或使用独立 `DisplayOrder` 的弹层:
```lua
local function ToastPortal(props)
return ReactRoblox.createPortal(
React.createElement(Toast, props.toastProps),
props.target
)
end
```
Portal target 的创建、复用和销毁必须有明确所有者。不要由多个组件各自创建同名 `ScreenGui`
## Fragment 与稳定 key
```lua
local children = {}
for _, item in items do
children["Item_" .. item.id] = React.createElement(ItemRow, {
item = item,
})
end
return React.createElement(React.Fragment, nil, children)
```
- 静态子节点使用 `Corner``Layout``Padding` 等语义 key。
- 动态项使用稳定业务 ID。
- 条件子节点保持固定 key将 value 设为 `nil` 来移除。
## ref
只有焦点、测量或与 Roblox Instance API 互操作时才暴露 ref。优先让组件内部处理自己的动画和焦点父组件只接收语义回调。
## Hybrid 边界
如果 Gate 选择 `Hybrid`
- React 树拥有页面结构与业务展示状态;
- 命令式桥接只处理世界空间挂载、特殊时序或项目既有实例接口;
- 桥接通过 props、信号适配器或受控 ref 通信;
- unmount 时由单一入口释放命令式资源;
- 禁止外部脚本直接修改 React 拥有的实例来绕过状态。
## 自查
- 所有包路径来自实际项目;
- 动态 children key 稳定;
- Context 不承载高频值;
- Portal target 不泄漏;
- ref 没有扩大为隐式公共 API
- 组件卸载后连接、task、Tween 和外部实例均清理。

View File

@@ -0,0 +1,182 @@
# React-Lua 自定义 Hooks
仅在项目已安装 `React-Lua` / `ReactRoblox`,或用户确认引入后使用。本页代码假设 `React` 已通过项目真实依赖路径导入;不要复制一个虚构的 `Packages.React` 路径。
## 设计原则
- 每个 `RBXScriptConnection``useEffect` cleanup 中断开。
- 每个 `task.delay` thread 在依赖变化和卸载时取消。
- callback 通过 ref 保持最新,避免仅因闭包变化反复订阅。
- Hook 不隐藏服务器权威逻辑。
- Hook 返回值保持小而明确,不建立第二套全局状态系统。
## useRBXSignal
```lua
--!strict
local function useRBXSignal(signal: RBXScriptSignal, callback: (...any) -> ())
local callbackRef = React.useRef(callback)
React.useEffect(function()
callbackRef.current = callback
end, { callback } :: { any })
React.useEffect(function()
local connection = signal:Connect(function(...)
callbackRef.current(...)
end)
return function()
connection:Disconnect()
end
end, { signal } :: { any })
end
```
## useClientEvent
直接接收已由项目网络层解析的 `RemoteEvent`,不在 Hook 中猜测远端路径。
```lua
local function useClientEvent(remote: RemoteEvent?, callback: (...any) -> ())
local callbackRef = React.useRef(callback)
React.useEffect(function()
callbackRef.current = callback
end, { callback } :: { any })
React.useEffect(function()
if remote == nil then
return
end
local connection = remote.OnClientEvent:Connect(function(...)
callbackRef.current(...)
end)
return function()
connection:Disconnect()
end
end, { remote } :: { any })
end
```
客户端收到事件后只更新展示;购买、奖励、伤害等结果仍由服务器验证。
## useTimeout
```lua
local function useTimeout(callback: () -> (), seconds: number)
local callbackRef = React.useRef(callback)
React.useEffect(function()
callbackRef.current = callback
end, { callback } :: { any })
React.useEffect(function()
local pending = task.delay(seconds, function()
callbackRef.current()
end)
return function()
task.cancel(pending)
end
end, { seconds } :: { any })
end
```
## useViewportSize
```lua
local function useViewportSize(): Vector2
local initialCamera = workspace.CurrentCamera
local initial = if initialCamera then initialCamera.ViewportSize else Vector2.zero
local viewport, setViewport = React.useState(initial)
React.useEffect(function()
local cameraConnection: RBXScriptConnection? = nil
local function bindCamera()
if cameraConnection then
cameraConnection:Disconnect()
cameraConnection = nil
end
local camera = workspace.CurrentCamera
if camera == nil then
setViewport(Vector2.zero)
return
end
setViewport(camera.ViewportSize)
cameraConnection = camera:GetPropertyChangedSignal("ViewportSize"):Connect(function()
setViewport(camera.ViewportSize)
end)
end
bindCamera()
local currentCameraConnection =
workspace:GetPropertyChangedSignal("CurrentCamera"):Connect(bindCamera)
return function()
currentCameraConnection:Disconnect()
if cameraConnection then
cameraConnection:Disconnect()
end
end
end, {})
return viewport
end
```
如果项目已有集中式响应布局信号,复用它,避免每个组件各自监听 `ViewportSize`
## useCharacter
角色 Hook 应同时处理 `CharacterAdded``CharacterRemoving` 和 Humanoid 连接替换:
```lua
type CharacterState = {
character: Model?,
humanoid: Humanoid?,
}
local function useCharacter(player: Player): CharacterState
local state, setState = React.useState({
character = player.Character,
humanoid = nil,
} :: CharacterState)
React.useEffect(function()
local function update(character: Model?)
setState({
character = character,
humanoid = if character then character:FindFirstChildOfClass("Humanoid") else nil,
})
end
local added = player.CharacterAdded:Connect(update)
local removing = player.CharacterRemoving:Connect(function()
update(nil)
end)
update(player.Character)
return function()
added:Disconnect()
removing:Disconnect()
end
end, { player } :: { any })
return state
end
```
## 高频视觉值
冷却进度、平滑位置和透明度优先 Binding业务状态仍用 state。RenderStep / Heartbeat 连接必须在 cleanup 中断开,并在 UI 隐藏时停止。
## 自查
- import 路径来自项目证据;
- cleanup 覆盖连接和 task
- 依赖数组包含 effect 读取的外部值;
- 没有在条件分支中调用 Hook
- 没有每个渲染帧触发结构性 state 更新;
- 重生、卸载和远端失效不会留下旧回调。

View File

@@ -0,0 +1,97 @@
# React-Lua 性能
仅在项目已具备 `React-Lua` / `ReactRoblox` 或用户确认依赖后使用。先测量,再优化;不要把固定实例数量当作所有设备都成立的阈值。
## 排查顺序
1. 在目标低端设备或 Studio 设备模拟中复现。
2. 区分 React 重渲染、Roblox 布局、文字测量、透明叠层、3D 预览和每帧脚本成本。
3. 用项目已有 profiler、MicroProfiler 或 `debug.profilebegin` / `debug.profileend` 标记热点。
4. 只修改已证明昂贵的路径。
5. 对比修改前后的帧耗时、实例数和内存。
## React 层
### React.memo
稳定 props 的重复行组件可用 `React.memo`。如果父组件每次都创建新 table 或 function浅比较仍会失效。
### useMemo
用于已测量为昂贵的排序、筛选和派生数据。依赖数组必须包含计算读取的全部外部值。
### useCallback
主要用于传给 memo 子组件或作为其他 Hook 依赖。不要机械包裹每个函数。
### Binding
平滑位置、透明度、进度条等高频视觉值使用 Binding避免每帧触发结构性重渲染。添加 / 删除 children 等结构变化仍使用 state。
## 长列表
按数据规模和设备实测渐进处理:
1. 稳定 key避免重排时整列 remount
2. memo 行组件和稳定 callback
3. 分页或按需加载;
4. 只在实测需要时实现窗口化;
5. 高频创建销毁的纯命令式装饰可考虑项目已有对象池。
窗口化必须保持正确 `CanvasSize`,并处理快速滚动、变高行、本地化和数据插入。
## Roblox 渲染层
重点检查:
- 大量同时更新的 `TextLabel`
- 深层 `CanvasGroup` 与透明叠层;
- 列表内重复 `UIStroke`、Gradient 和阴影层;
- 多个动态 `ViewportFrame`
- 频繁增删 children 触发布局;
- 每帧从 Luau 写入大量实例属性。
能由 `TweenService` 完成的属性过渡,不建立独立 Heartbeat 循环。循环确有必要时,共享单一时钟并在 UI 隐藏后停止。
## 隐藏还是卸载
- 高频开关且重建昂贵的 HUD可保持 mounted通过 `Visible` 控制。
- 很少打开且子树大的页面:可在关闭后 unmount。
- 有退出动效:使用 `leaving` 状态,结束后 unmount销毁请求不等待动效。
选择基于实测和交互频率,不使用固定百分比规则。
## 示例:稳定列表
```lua
local Row = React.memo(function(props)
return React.createElement("TextLabel", {
LayoutOrder = props.order,
Text = props.label,
Size = UDim2.new(1, 0, 0, props.height),
BackgroundTransparency = 1,
})
end)
local children = {
Layout = React.createElement("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
}),
}
for order, item in items do
children["Item_" .. item.id] = React.createElement(Row, {
order = order,
label = item.label,
height = rowHeight,
})
end
```
## 验收
- profiler 证明目标热点下降;
- 手机上滚动和页面切换无明显卡顿;
- 优化没有破坏 key、焦点、动效和生命周期
- 隐藏页面不再运行无意义循环;
- 没有为未测量问题新增复杂缓存或第三方依赖。

View File

@@ -0,0 +1,119 @@
# React-Lua 状态管理
仅在项目已具备 `React-Lua` / `ReactRoblox` 或用户确认依赖后使用。选择项目已有能力,不默认引入 Rodux 或其他 Store。
## 选择表
| 状态 | 首选 |
| --- | --- |
| 局部开关、输入、选择项 | `useState` |
| 多字段且转移规则复杂 | `useReducer` |
| 高频视觉属性 | Binding |
| 低频树级数据 | Context |
| 跨页面全局状态 | 项目已有 Store |
| 外部 Roblox 信号 | 适配 Hook + cleanup |
## useState
新值依赖旧值时使用函数式更新:
```lua
local count, setCount = React.useState(0)
local function increment()
setCount(function(previous)
return previous + 1
end)
end
```
昂贵初始值用 initializer function只在首次挂载计算。
## useReducer
适合状态转移比单个 setter 更重要的页面:
```lua
type State = {
status: "idle" | "loading" | "ready" | "error",
selectedId: string?,
errorMessage: string?,
}
type Action =
{ type: "LOAD" }
| { type: "READY" }
| { type: "FAIL", message: string }
| { type: "SELECT", id: string }
local function reducer(state: State, action: Action): State
local nextState = table.clone(state)
if action.type == "LOAD" then
nextState.status = "loading"
nextState.errorMessage = nil
elseif action.type == "READY" then
nextState.status = "ready"
elseif action.type == "FAIL" then
nextState.status = "error"
nextState.errorMessage = action.message
elseif action.type == "SELECT" then
nextState.selectedId = action.id
end
return nextState
end
```
reducer 保持纯函数,不发 RemoteEvent、不创建 Instance。
## Context
Context 适合主题、页面路由上下文和低频用户设置。value 使用 `useMemo`,避免每次 render 创建新 table。
不要把倒计时、角色位置或每帧动画放入 ContextContext 更新会影响所有消费者。
## Binding
Binding 用于已有元素的高频属性,不用于决定 children 是否存在:
```lua
local progress, setProgress = React.useBinding(0)
return React.createElement("Frame", {
Size = progress:map(function(value)
return UDim2.fromScale(math.clamp(value, 0, 1), 1)
end),
})
```
更新来源的连接必须在 effect cleanup 中断开。
## 项目 Store
只有探测到项目已使用 Store 时才接入:
- 复用现有 selector、dispatch、provider 和类型;
- 不为单个页面建立第二个 Store
- 高频视觉值不要进入全局 Store
- 服务器数据进入 Store 前仍需经过现有网络与验证层。
如果用户要求新增 Store 依赖,先说明版本、来源、许可、维护状态、锁文件影响和原生替代方案并等待确认。
## 外部系统桥接
React 树与命令式系统通信时:
- 优先使用项目已有信号或适配层;
- 明确谁拥有连接和销毁;
- 避免双方同时写同一状态;
- RemoteEvent 只传递请求或服务器确认结果;
- 页面卸载后忽略迟到响应,或使用 request token 区分生命周期。
## 自查
- 每个状态有唯一权威来源;
- reducer 无副作用;
- Context 不承载高频值;
- Binding 不控制结构变化;
- Store 来自项目证据或已确认依赖;
- 远端结果不由客户端伪造;
- 卸载后连接和异步回调不会更新旧树。

View File

@@ -0,0 +1,74 @@
# Roblox UI 框架与依赖选择
本页只在 Framework Decision Gate 评估声明式路线时读取。任何外部依赖都必须先证明项目已安装,或按依赖治理流程获得用户确认。
## 探测证据
查找:
- 包管理声明、锁文件和包目录;
- `require` 指向 `React``ReactRoblox`、Roact、Fusion、Vide 或状态库的实际路径;
- 根挂载、卸载和测试示例;
- 项目内 Design System、动画和响应式模块
- 维护文档与约定版本。
不要根据目录名猜测依赖可用,也不要把本页示例写成默认包路径。
## 选择矩阵
| 路线 | 适合 | 前置条件 |
| --- | --- | --- |
| Roblox 原生命令式 | 小型 UI、时序主导、无框架项目 | 无外部依赖 |
| React-Lua / ReactRoblox | 复杂状态、可复用组件、长期页面系统 | 项目已安装,或用户确认引入 |
| Roact 兼容路线 | 维护既有 Roact 代码 | 项目已有且迁移成本不合理 |
| Fusion / Vide 等响应式框架 | 项目已有统一实践 | 项目已安装并有团队约定 |
| Hybrid | 声明式页面 + 命令式世界空间或时序桥接 | 两侧所有权边界明确 |
“更现代”或“页面更复杂”不能单独构成新增依赖理由。
## 新增依赖确认清单
引入前向用户说明:
```text
名称与用途:
目标版本:
来源:
许可:
维护状态:
运行时 / 工具链兼容:
锁文件与构建影响:
项目已有替代方案:
移除或回滚成本:
```
未确认时提供原生 API 方案或仅提交接口设计。
## 动画
- `TweenService`Roblox 原生,适合明确的属性过渡。
- React 动画库:仅在项目已有或用户确认后使用,并验证与当前 React-Lua 版本兼容。
- 框架无关的 spring 库:只在项目已有对应封装时复用。
不为一个按钮反馈引入整套动画依赖。
## 状态管理
按最小范围选择:
1. 局部状态:`useState` / `useReducer`
2. 高频视觉值Binding。
3. 低频树级数据Context。
4. 跨页面全局状态:先复用项目已有 Store。
5. 项目没有 Store 时,不因单页需求默认引入 Rodux 或其他状态库。
## 决策输出
```text
现有依赖证据:
选择路线:
复用模块:
新增依赖:无 / 待确认
版本兼容证据:
原生替代方案:
```

View File

@@ -0,0 +1,74 @@
# Roblox 与 React-Lua UI 限制
仅在项目已具备 `React-Lua` / `ReactRoblox` 或用户确认依赖后读取 React 部分。Roblox API 会演进;遇到不确定属性或行为时,以项目当前引擎和官方文档为准。
## Roblox 渲染
| 限制或风险 | 常见处理 |
| --- | --- |
| 没有 CSS 式通用阴影 | 使用经确认的图片资源或少量偏移层 |
| `UIGradient` 以线性渐变为主 | 复杂渐变使用项目已有图片或绘制方案 |
| 大量透明叠层昂贵 | 减少 `CanvasGroup` 嵌套并实测 |
| ZIndex 受挂载层级影响 | 统一弹层根、`DisplayOrder``ZIndexBehavior` |
| 旋转、裁剪组合可能有边界问题 | 在目标设备验证,必要时改变层级或资源方案 |
不要为视觉替代静默下载图片、字体、插件或库。
## 布局
- 一个容器内的布局对象应有单一职责;复杂布局用嵌套容器。
- `AutomaticSize`、文字换行和父级约束可能形成循环,先定义主轴。
- 嵌套滚动区域容易产生输入冲突,优先重构信息架构。
- `AbsoluteSize` 首帧可能尚未稳定,测量逻辑要容忍零值和后续变化。
- SafeArea 自动处理与手动 inset 不得重复。
## 文本
- RichText 不能替代完整的文档排版系统。
- 自定义字体受资产上传、审核和项目许可约束。
- IME、本地化长文本、无空格语言需要真实设备验证。
- 文字测量 API 和 Font 用法必须与项目当前引擎一致。
## 图片与 3D 预览
- 图片资产必须有明确来源、许可和 AssetId 管理方式。
- 动态图片能力、尺寸和内存限制应查当前官方文档。
- `ViewportFrame` 增加独立 3D 渲染成本;数量、模型复杂度和更新频率都要测量。
- SVG 或复杂矢量需求先确认当前引擎支持;不支持时使用经许可的栅格资源。
## 交互
- Hover 不适用于触控,不能作为唯一反馈。
- 手柄焦点不会自动符合复杂业务流程,需要显式验证。
- 拖拽要处理取消、目标失效和输入方式切换。
- 装饰子节点可能拦截 `GuiButton`,需检查 `Active``Selectable``Interactable`
- 弹层隐藏后仍可能有透明遮罩拦截输入。
## React-Lua
- 可用 Hooks、并发特性和 API 取决于项目实际版本,不按 Web React 版本推断。
- 包导入路径、root 创建与卸载方式必须从项目代码确认。
- Binding 适合高频属性更新,但不能替代结构状态。
- Portal target 必须由明确的生命周期所有者管理。
- 错误边界、动画和 Store 库都属于额外能力;项目未安装时不得默认使用。
- 与命令式代码混用时,禁止双方直接修改同一实例状态。
## 替代方案决策
遇到限制时按顺序处理:
1. 简化视觉或交互;
2. 使用 Roblox 原生组件;
3. 复用项目已有工具;
4. 使用已有且许可明确的资产;
5. 最后才提出新增依赖或资源,并等待用户确认。
## 验证清单
- API 在项目当前版本存在;
- PC、手机和手柄行为一致
- SafeArea 和滚动没有冲突;
- 视觉替代没有明显性能回退;
- 外部资源已经确认来源与许可;
- React API 与项目版本匹配;
- 卸载后无 root、Portal、连接或 task 泄漏。

View File

@@ -0,0 +1,146 @@
# React-Lua 响应式 UI
仅在项目已具备 `React-Lua` / `ReactRoblox` 或用户确认依赖后使用。不要默认假设项目存在名为 `ResponsiveUI` 的模块;先探测并复用真实能力。
## 决策依据
布局优先依据可用 viewport 和容器尺寸,其次才是输入设备:
- 手机可能连接键盘或手柄;
- 触屏设备可能拥有大屏;
- 桌面窗口可能比手机横屏更窄;
- 横竖屏变化可在运行时发生。
常用断点应来自项目设计 Token。没有既有标准时`UI Design Brief` 中提出并让用户确认。
## useViewportSize
下面示例会处理 `CurrentCamera` 被替换:
```lua
local function useViewportSize(): Vector2
local initialCamera = workspace.CurrentCamera
local viewport, setViewport = React.useState(
if initialCamera then initialCamera.ViewportSize else Vector2.zero
)
React.useEffect(function()
local cameraConnection: RBXScriptConnection? = nil
local function bindCamera()
if cameraConnection then
cameraConnection:Disconnect()
cameraConnection = nil
end
local camera = workspace.CurrentCamera
if camera == nil then
setViewport(Vector2.zero)
return
end
setViewport(camera.ViewportSize)
cameraConnection = camera:GetPropertyChangedSignal("ViewportSize"):Connect(function()
setViewport(camera.ViewportSize)
end)
end
bindCamera()
local currentCameraConnection =
workspace:GetPropertyChangedSignal("CurrentCamera"):Connect(bindCamera)
return function()
currentCameraConnection:Disconnect()
if cameraConnection then
cameraConnection:Disconnect()
end
end
end, {})
return viewport
end
```
若项目已有集中式 viewport Store / Hook复用它避免每个组件重复监听。
## 纯函数布局
把尺寸计算保持为纯函数,便于测试:
```lua
type Layout = {
panelSize: UDim2,
columns: number,
padding: number,
compact: boolean,
}
local function calculateLayout(viewport: Vector2): Layout
local compact = viewport.X < 720 or viewport.Y < 520
local panelWidth = if compact then 0.94 else 0.76
local panelHeight = if compact then 0.88 else 0.8
return {
panelSize = UDim2.fromScale(panelWidth, panelHeight),
columns = if viewport.X < 560 then 2 else if viewport.X < 980 then 3 else 4,
padding = if compact then 8 else 12,
compact = compact,
}
end
```
组件通过 `useMemo` 按 viewport 重新计算,不在多个子组件复制断点。
## SafeArea
优先选择一种:
1. 使用 `ScreenGui.ScreenInsets` 让引擎处理;
2. 复用项目已有的安全区模块;
3. 仅当项目确有需求时,集中计算并注入 inset。
禁止自动 inset 与手动 padding 重复应用。背景可以全屏,按钮、文本和输入区域仍应位于安全区。
## 容器响应
页面嵌在其他 UI 中时,`CurrentCamera.ViewportSize` 不等于实际可用尺寸。使用容器 `AbsoluteSize` 或项目已有测量 Hook并在变化时更新。
避免:
- 模块加载时只计算一次尺寸;
- 直接使用未经 Token 处理的大量 raw pixel
- 仅靠 `TouchEnabled` 切换手机布局;
- 横屏只缩小整页而不重排内容;
- 将 SafeArea 值写死为某款设备尺寸。
## 设备策略
### 手机竖屏
- 减少列数;
- CTA 靠近自然触达区;
- 次要信息折叠或滚动;
- 弹窗宽度接近 viewport但保留安全边距。
### 手机横屏
- 高度通常是主要瓶颈;
- 压缩顶部装饰和标题区;
- 避开虚拟摇杆与动作按钮;
- 长内容使用内部滚动,不让 CTA 移出屏幕。
### 桌面 / 主机
- 设置面板最大宽高,避免 4K 屏幕无限拉伸;
- 主机检查 ten-foot 可读性和焦点导航;
- 窄窗口也应触发紧凑布局。
## 验证矩阵
- viewport 从宽变窄、横竖屏切换;
- 容器动态改变 `AbsoluteSize`
- SafeArea 只应用一次;
- 本地化长文本;
- 触控按钮与游戏操作区不重叠;
- 页面隐藏后不再监听无关尺寸变化;
- 根卸载后监听全部断开。

View File

@@ -0,0 +1,92 @@
# UI 动效 Token 与生命周期
动效参数集中配置,调用方只引用语义名称。
```lua
return {
pageIn = {
duration = 0.24,
style = Enum.EasingStyle.Quad,
direction = Enum.EasingDirection.Out,
startScale = 0.94,
},
pageOut = {
duration = 0.16,
style = Enum.EasingStyle.Quad,
direction = Enum.EasingDirection.In,
},
buttonPress = {
duration = 0.08,
scale = 0.96,
},
modalIn = {
duration = 0.22,
startScale = 0.9,
},
listItemIn = {
duration = 0.14,
stagger = 0.025,
},
}
```
## 可取消 Tween
以下模式只依赖 Roblox 原生 API可嵌入项目已有生命周期对象
```lua
local TweenService = game:GetService("TweenService")
local activeTweens: { [string]: Tween } = {}
local function cancelTween(key: string)
local current = activeTweens[key]
if current then
current:Cancel()
activeTweens[key] = nil
end
end
local function playTween(key: string, target: Instance, info: TweenInfo, goal: { [string]: any })
cancelTween(key)
local tween = TweenService:Create(target, info, goal)
activeTweens[key] = tween
tween.Completed:Once(function()
if activeTweens[key] == tween then
activeTweens[key] = nil
end
end)
tween:Play()
return tween
end
local function destroyAnimations()
for key in activeTweens do
cancelTween(key)
end
end
```
## 设计规则
1. 反馈优先清晰,不用长动效延迟操作。
2. 动效必须可中断;退出、销毁和重复触发走同一清理入口。
3. 使用 `UIScale` 做视觉按压,避免改 `Size` 引发布局抖动。
4. 需要整组淡入淡出时才用 `CanvasGroup`;大量嵌套前先测量成本。
5. 循环动效必须有停止条件,页面隐藏后不得继续更新。
6. 手机端降低并发、错峰数量、shake 幅度和大面积透明度动画。
7. 不把 `task.delay` 当作可靠时间线;需要保存 thread 并在销毁时 `task.cancel`
## 路线条件
- 原生命令式:使用 `TweenService` 和上面的所有权模式。
- 如果项目探测到既有 `UIUtils``Controller`,可把取消、播放和销毁映射到其现有契约。
- 只有项目已安装或用户确认引入 `React-Lua` 时,才采用 Hooks / Binding 版本;具体模式见 React-Lua 动效文档。
## 验证
- 快速连续点击不会叠加 Tween。
- 动效中关闭、重开或销毁不会回写已销毁实例。
- `Completed` 回调不会覆盖较新的 Tween 状态。
- 低帧率下最终状态正确。
- 手机端没有大幅旋转、闪烁或过长阻塞。

View File

@@ -0,0 +1,197 @@
# UI 组件模式
本页默认使用 Roblox 原生 `Instance` API。只有项目探测到既有 `UIUtils``Controller` 或用户确认相应架构后,才把示例映射到那些抽象。
## 1. 生命周期所有者
页面必须集中拥有连接、Tween、延时任务和根实例。
```lua
--!strict
local Page = {}
local connections: { RBXScriptConnection } = {}
local threads: { thread } = {}
local rootGui: ScreenGui? = nil
local isOpen = false
local function track(connection: RBXScriptConnection): RBXScriptConnection
table.insert(connections, connection)
return connection
end
function Page.Open()
if isOpen then
return
end
isOpen = true
if rootGui then
rootGui.Enabled = true
end
end
function Page.Close()
if not isOpen then
return
end
isOpen = false
if rootGui then
rootGui.Enabled = false
end
end
function Page.Destroy()
Page.Close()
for _, connection in connections do
connection:Disconnect()
end
table.clear(connections)
for _, pending in threads do
task.cancel(pending)
end
table.clear(threads)
if rootGui then
rootGui:Destroy()
rootGui = nil
end
end
return Page
```
若项目已有页面服务或清理容器,复用其接口,不再建立第二套所有权系统。
## 2. 原生装饰 Helper
局部 helper 可以减少重复,但不要伪装成项目已有公共 API
```lua
local function addCorner(parent: GuiObject, radius: UDim): UICorner
local corner = Instance.new("UICorner")
corner.CornerRadius = radius
corner.Parent = parent
return corner
end
local function addPadding(parent: GuiObject, amount: number): UIPadding
local padding = Instance.new("UIPadding")
local value = UDim.new(0, amount)
padding.PaddingTop = value
padding.PaddingBottom = value
padding.PaddingLeft = value
padding.PaddingRight = value
padding.Parent = parent
return padding
end
```
## 3. 响应式页面骨架
```lua
local screen = Instance.new("ScreenGui")
screen.Name = "FeaturePage"
screen.ResetOnSpawn = false
screen.ScreenInsets = Enum.ScreenInsets.CoreUISafeInsets
local panel = Instance.new("Frame")
panel.Name = "Panel"
panel.AnchorPoint = Vector2.new(0.5, 0.5)
panel.Position = UDim2.fromScale(0.5, 0.5)
panel.BackgroundColor3 = tokens.colors.surface
panel.Parent = screen
local sizeConstraint = Instance.new("UISizeConstraint")
sizeConstraint.MinSize = Vector2.new(280, 240)
sizeConstraint.MaxSize = Vector2.new(960, 720)
sizeConstraint.Parent = panel
local function updateLayout(viewport: Vector2)
local mobile = viewport.X < 700 or viewport.Y < 500
local widthRatio = if mobile then 0.94 else 0.76
local heightRatio = if mobile then 0.88 else 0.8
panel.Size = UDim2.fromScale(widthRatio, heightRatio)
end
```
如果项目采用手动 SafeArea不要同时设置会自动应用相同 inset 的 `ScreenInsets`
## 4. 列表和网格
- `ScrollingFrame.AutomaticCanvasSize` 配合单一布局对象。
- 使用稳定数据 ID 维护列表项身份,不用可变索引代表业务实体。
- 数据量大时先测量;再决定分页、窗口化或复用实例。
- 手机横屏优先减少列数和卡片高度,确保主要操作仍可见。
```lua
local scroll = Instance.new("ScrollingFrame")
scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y
scroll.CanvasSize = UDim2.fromOffset(0, 0)
scroll.ScrollBarThickness = 6
scroll.Size = UDim2.fromScale(1, 1)
scroll.BackgroundTransparency = 1
local layout = Instance.new("UIListLayout")
layout.Padding = UDim.new(0, tokens.spacing.sm)
layout.SortOrder = Enum.SortOrder.LayoutOrder
layout.Parent = scroll
```
## 5. 模态框
模态框至少包含:
- 可点击遮罩与明确关闭按钮;
- `Visible` / `Enabled` 的唯一状态来源;
- 打开前焦点保存,关闭后焦点恢复;
- 可取消进入 / 退出 Tween
- 防止背景按钮穿透;
- 手机尺寸上限与 SafeArea 边距。
不要仅隐藏内容而保留全屏透明按钮拦截输入。
## 6. 数据绑定边界
```lua
local function bindChanged<T>(
signal: RBXScriptSignal,
read: () -> T,
render: (T) -> (),
trackConnection: (RBXScriptConnection) -> ()
)
render(read())
trackConnection(signal:Connect(function()
render(read())
end))
end
```
客户端只展示服务器确认的数据。购买、奖励、装备等权威结果不能由 UI 本地状态决定。
## 7. GuiButton 输入卫生
移动端按钮无响应时,先检查完整 hit-test 路径:
1. 目标 `GuiButton` 是否可见、启用并位于触点;
2. 装饰性 `Frame``ImageLabel``TextLabel` 是否意外开启 `Active``Selectable``Interactable`
3. 遮罩、弹窗或高层级容器是否仍在拦截;
4. 复制的模板是否共享错误属性;
5. `Activated` 是否被业务层的禁用条件提前返回。
装饰节点通常应设置:
```lua
decoration.Active = false
decoration.Selectable = false
decoration.Interactable = false
```
除非资源无法修改,不要用额外输入旁路掩盖错误的实例属性。
## 8. 销毁检查
- 所有连接已断开;
- 所有 Tween 和 task 已取消;
- 输入与选择焦点已恢复;
- 页面根实例已销毁或交还已有页面管理器;
- 异步回调会验证实例和本次生命周期 token
- 重复 `Destroy()` 不报错。

View File

@@ -0,0 +1,118 @@
# UI 设计规范模板
在编码前填充本模板,并删除不适用项。
## 1. 目标与信息架构
```text
页面目标:
主要用户任务:
第一视觉焦点:
核心 CTA
次要操作:
入口 / 退出:
加载 / 空 / 错误状态:
```
## 2. 视觉 Token
Token 名称可映射到项目已有 Design System未发现现有系统时使用当前功能域内的配置模块不新建外部依赖。
```lua
return {
colors = {
primary = Color3.fromRGB(80, 140, 255),
surface = Color3.fromRGB(28, 32, 42),
elevated = Color3.fromRGB(38, 44, 58),
textPrimary = Color3.fromRGB(245, 247, 255),
textMuted = Color3.fromRGB(165, 174, 196),
success = Color3.fromRGB(62, 196, 116),
warning = Color3.fromRGB(244, 190, 62),
error = Color3.fromRGB(230, 82, 82),
},
spacing = {
xs = 4,
sm = 8,
md = 12,
lg = 16,
xl = 24,
},
radius = {
sm = UDim.new(0, 4),
md = UDim.new(0, 8),
lg = UDim.new(0, 12),
},
type = {
title = 28,
heading = 20,
body = 16,
caption = 12,
},
}
```
记录 Token 的来源、作用域和覆盖规则,避免页面间出现同名不同义。
## 3. 组件表
每个组件说明:
```text
名称:
职责:
输入:
输出事件:
视觉状态Normal / Hover / Pressed / Disabled / Selected / Loading
数据状态Loading / Empty / Error / Ready
PC 布局:
手机布局:
生命周期所有者:
可访问性 / 手柄焦点:
```
## 4. 响应式布局
至少填写:
- 基准 viewport 与允许范围;
- 面板最大宽高和屏幕占比;
- 手机横竖屏的列数、密度和滚动策略;
- 文字缩放的最小值与最大值;
- 触控目标尺寸与间距;
- `ViewportSize` 或容器尺寸变化时的刷新入口。
不要只按 `TouchEnabled` 判断布局;可触控设备也可能使用大屏。以可用尺寸为主,输入类型为辅。
## 5. SafeArea
选择一种策略并记录:
```text
[ ] 使用 ScreenGui.ScreenInsets
[ ] 复用项目已有 inset 计算
[ ] 仅背景越界,交互内容仍在安全区
```
禁止同时自动应用和手动叠加同一组 inset。关闭按钮、底部 CTA、虚拟摇杆附近 HUD 必须单独检查。
## 6. 动效
```text
进入:
退出:
状态切换:
按钮反馈:
可中断规则:
低性能 / 减少动效策略:
```
时长、缓动和幅度必须引用 Token。快速重复操作时新动效应取消或接管旧动效。
## 7. 验收矩阵
- PC鼠标、键盘、窗口缩放。
- 手机竖屏SafeArea、单手触达、文字和滚动。
- 手机横屏:高度不足、虚拟控制区避让。
- 手柄:焦点顺序、返回操作、焦点丢失恢复。
- 本地化:长文本、无空格语言、数字格式。
- 生命周期:重复打开、关闭中重开、销毁、角色重生。

View File

@@ -0,0 +1,84 @@
# Roblox UI 平台约束
实现前优先检查项目当前引擎版本和官方 API本页提供决策原则不替代 API 校验。
## 布局
| 需求 | 首选 |
| --- | --- |
| 页面或大面板占比 | `UDim2` 的 Scale 分量 |
| 间距、描边、小图标 | Offset + Token |
| 列表 | `UIListLayout` |
| 网格 | `UIGridLayout` |
| 保持比例 | `UIAspectRatioConstraint` |
| 限制尺寸 | `UISizeConstraint` |
| 限制字体 | `UITextSizeConstraint` |
| 内容驱动高度 | `AutomaticSize` |
| 滚动内容高度 | `AutomaticCanvasSize` |
同一容器需要多种布局时使用嵌套容器,不在一个父节点堆叠互相竞争的布局约束。
## SafeArea
- 普通交互页面尊重 `ScreenGui.ScreenInsets`
- 全屏背景可以铺到边缘,但交互内容仍放在安全区容器中。
- 如果项目已有手动 inset 方案,沿用它并关闭重复的自动补偿。
- 不写死顶栏、刘海或底部手势区像素。
- HUD 还要避开游戏自己的触控按钮和虚拟摇杆区域。
## 输入
- 按钮优先使用 `GuiButton.Activated`,覆盖鼠标、触控与手柄激活。
- Hover 不是移动端能力,不能承载唯一信息。
- 手柄需要明确焦点顺序、默认焦点和返回路径。
- 装饰子节点不得拦截触控。
- 拖拽需要处理取消、离开窗口、方向变化和目标失效。
## 文本与本地化
- 标题、按钮和标签都要测试长文本。
- 使用 `TextWrapped``TextTruncate` 或滚动的选择必须由内容职责决定。
- `TextScaled` 应配合 `UITextSizeConstraint`,防止极小或极大字号。
- 字体、富文本标签和自定义字体资产必须按项目现状验证。
- 不用字符串长度估算渲染宽度;需要精确布局时使用项目当前可用的文字测量 API。
## 渲染与视觉替代
- 复杂阴影通常使用受控的图片资源或轻量偏移层;避免列表中大量叠层。
- `UIGradient` 适合线性渐变;复杂渐变使用经确认的图片资源或项目已有绘制方案。
- `CanvasGroup` 便于整组透明度,但可能增加渲染成本,避免深层嵌套。
- `ViewportFrame` 是独立 3D 渲染区域;数量和更新频率应通过目标设备测量。
- `UIStroke`、透明叠层和动态图片在长列表中应保守使用。
## ZIndex 与弹层
- 先确定项目的 `ZIndexBehavior``DisplayOrder` 约定。
- 弹窗、Tooltip、Toast 的挂载层由页面层级策略决定。
- 不通过随意增大 `ZIndex` 修复错误的父子层级。
- 隐藏弹层时确认遮罩也被禁用,避免透明拦截。
## 生命周期
- `ScreenGui.ResetOnSpawn` 必须根据页面职责显式选择。
- 角色重生、回合切换、传送、页面重建时均应清理连接和异步任务。
- Tween 完成回调执行前再次确认生命周期仍有效。
- 高频监听使用单一订阅点或项目已有信号聚合,避免每个子组件重复监听 viewport。
## 性能验证
不要依赖固定数量阈值断言性能。按以下顺序处理:
1. 在低端目标设备复现;
2. 找出实例数、布局、文字、透明叠层或更新频率热点;
3. 减少无效重建与每帧更新;
4. 再考虑分页、窗口化、对象复用或视觉降级;
5. 对比修改前后的帧耗时和内存。
## 发布前检查
- PC、手机横竖屏和手柄均可操作
- SafeArea 没有重复应用;
- 长文本不会遮挡 CTA
- 列表可滚动且滚动条不阻挡按钮;
- 弹层不会穿透或遗留输入遮罩;
- 页面销毁后没有连接、Tween、task 或实例泄漏。

View File

@@ -0,0 +1,191 @@
---
name: roblox-vfx-designer
description: >-
设计并集成 Roblox 客户端视觉特效覆盖粒子、Beam、Trail、光环、法阵、冲击波、
命中反馈与技能演出。用于处理 VFX、粒子、拖尾、法阵、冲击波、光环或命中特效
先探测 Rojo 映射与项目现有特效架构,再按项目源码实现,并提供 Studio 手工验证步骤。
---
# Roblox VFX Designer
将需求转化为可维护的分镜、实例结构、参数配置和 Luau 播放逻辑。主交付物必须落在项目源码或项目已经采用的资源格式中Studio MCP 只在当前环境确实可用时辅助验证。
## 核心规则
1. **先探测后集成**:不得假定项目存在特定 VFX 模块、Prefab 目录或播放 API。
2. **源码为主**:先读取 Rojo 配置、目录映射和现有 Luau/资源文件,再决定实现位置与接口。
3. **客户端渲染**:纯视觉效果在客户端播放;服务端只发送经过校验的玩法事件和必要参数。
4. **瞬发用 Burst**:瞬发粒子优先 `ParticleEmitter:Emit(count)`;持续效果才使用受预算约束的 `Rate`
5. **父级合法**`ParticleEmitter` 可直接挂在 `Attachment``BasePart` 下。前者点发射,后者按 Part 形状/体积发射;按需求选择,不强制统一为 Attachment。
6. **连接合法**Beam/Trail 的 `Attachment0``Attachment1` 必须引用有效 Attachment并随生命周期一起清理。
7. **参数外置**:可调的数量、时长、颜色、尺寸、速度、距离和质量档位放入项目现有配置层;没有配置层时使用局部 config table避免散落魔法数。
8. **生命周期完整**每个实例、连接、Tween、延迟任务和循环都要有停止或销毁路径。
9. **移动端默认**:未指定时产出 Gameplay 档Hero 档仅用于低频演出,并说明同屏上限。
10. **不硬编码新资源 ID**:贴图或模型资源先确认项目资源约定。上传、审核和权限操作需由用户在相应账号下完成。
11. **不覆盖 authored data**:修复现有 Prefab 时优先保留层级与视觉参数,只改明确有问题的运行时字段。
12. **Studio 工具可选**:没有 Studio MCP 不能阻塞设计、源码实现、静态检查或手工验证说明。
## 阶段 0探测项目
在设计前完成以下检查,并记录证据路径:
1. 查找 `default.project.json` 或其他 Rojo project 文件,确认 `ReplicatedStorage``StarterPlayer``StarterGui``ServerScriptService` 等映射。
2. 搜索项目源码中的 `VFX``Effect``ParticleEmitter``Beam``Trail``Emit(``Play``Stop``Cleanup` 等关键词。
3. 找出现有 Prefab/模板存储方式Rojo 目录、`.rbxm/.rbxmx`、构建脚本、配置表或仅 Studio 场景资产。
4. 阅读候选模块的真实函数签名、调用方、客户端边界与清理逻辑;模块名相似不能代替 API 验证。
5. 查找同类效果作为风格与性能基线,并区分项目通用模式和一次性业务代码。
根据结果选择:
- **已有通用系统且能力匹配**:沿用其真实接口。
- **已有系统但缺少能力**:先证明缺口,再提出最小、通用、带清理路径的扩展。
- **只有局部工具或旧代码**:复用可靠部分,补一个符合项目结构的薄适配层。
- **没有特效系统**:实现最小客户端模块或局部组件;不要伪造并不存在的注册表/API。
- **资源只保存在 Studio**:源码中仍应保留播放逻辑、参数或可复现的构建脚本,并列出明确的 Studio 物化步骤。
详细决策树见 [docs/project-integration.md](docs/project-integration.md)。
## 阶段 1需求与预算
至少确认:
- 效果类型、风格、触发场景、锚点/范围和持续时间;
- Gameplay 或 Hero 档,以及可能的最大同屏数量;
- 玩法范围Radius、MaxDistance、ProjectileSpeed 等);
- 是否已有可复用纹理、Prefab 或同类效果;
- 客户端由哪个事件、Controller 或组件触发。
信息不足时询问关键缺口,不猜项目 API、AssetId 或视觉风格。
### 默认性能档
| 类型 | Gameplay默认 | Hero |
|---|---:|---:|
| 瞬发 PE 数 | ≤ 4 | ≤ 10 |
| 单次 Burst 总粒子 | ≤ 30 | ≤ 80 |
| 持续 PE 数 | ≤ 3 | ≤ 6 |
| 持续单 PE Rate | ≤ 30/s | ≤ 80/s |
| 持续特效估算存活粒子 | ≤ 20 | ≤ 50 |
| PointLight | 0 | ≤ 1 |
持续粒子的主要估算是 `Σ(Rate × Lifetime.Max)`。预算还需乘以最大同屏特效数超预算时先减少层数、Rate 和 Lifetime再考虑视觉替代。
## 阶段 2分镜与结构
先写时间轴,再写代码。时间轴至少标注:
- 主层、次层、点缀层的入场顺序;
- 高光/Burst 时刻;
- 跟随、锁定朝向或世界坐标行为;
- 衰减和清理时刻;
- 玩法命中时刻与视觉时刻是否需要分离。
### ParticleEmitter 父级选择
点发射:
```text
EffectRoot (Part)
└─ Center (Attachment)
└─ MainBurst (ParticleEmitter)
```
体积或表面发射:
```text
EffectVolume (Part, Size = 目标覆盖范围)
├─ MainLayer (ParticleEmitter)
└─ AccentLayer (ParticleEmitter)
```
选择依据:
- 枪口、手心、脚底、关节等精确点位:挂 `Attachment`
- 全身燃烧、区域雾、盒/球/圆柱体积:直接挂合法 `BasePart`,并设置 `Shape``ShapeStyle``ShapeInOut`
- `Attachment` 必须位于支持 Attachment 的合法父级下,不能悬挂在 Folder/Model 中。
- Part 大小不会扩大 Attachment 子级粒子的发射区域。
组件选择与组合见 [docs/vfx-components.md](docs/vfx-components.md) 和 [docs/vfx-patterns.md](docs/vfx-patterns.md)。
## 阶段 3参数与距离校准
每层至少明确:
```lua
{
EmitCount = 12,
Lifetime = NumberRange.new(0.4, 0.8),
Speed = NumberRange.new(4, 10),
SpreadAngle = Vector2.new(25, 25),
Size = NumberSequence.new(...),
Transparency = NumberSequence.new(...),
Color = ColorSequence.new(...),
Drag = 2,
Acceleration = Vector3.new(0, -5, 0),
}
```
粒子位移粗估:
```text
Drag > 0: distance ≈ avgSpeed / Drag × (1 - exp(-Drag × avgLifetime))
Drag = 0: distance ≈ avgSpeed × avgLifetime
```
体积发射的视觉半径还要叠加发射体本身的半尺寸。该公式用于初筛不替代运行时观察Roblox 实际运动还受 Acceleration、发射方向、Shape 和局部坐标影响。
调参和可见性基线见 [docs/parameter-tuning.md](docs/parameter-tuning.md)。
## 阶段 4源码实现
1. 按阶段 0 找到的 Rojo 映射创建或修改 Luau、配置和项目已采用的资源文件。
2. 使用项目真实 API调用前核对参数顺序、返回值、重复播放策略和停止语义。
3. Prefab 不能由当前源码格式表达时,提供可复现的 Studio 构建脚本或逐属性手工步骤,不把只存在于临时 Studio 会话中的对象当作完成。
4. 将时间轴映射为明确的 Tween、延迟或帧更新保存连接句柄并在完成、取消、目标销毁时清理。
5. 持续 PE 停止时先 `Enabled = false`,通常等待最大 Lifetime 后销毁,避免粒子硬切;目标提前销毁时允许立即清理。
6. 恢复既有 Prefab 前先保留可回滚副本或版本控制记录,不销毁唯一来源。
## 阶段 5验证
### 无 Studio MCP 的主路径
1. 运行项目声明的 formatter、linter、type checker 和测试;先确认运行时版本与现有命令。
2. 启动项目规定的 Rojo 同步流程,在 Studio 打开正确 Place 并连接。
3. 在 Explorer 核对映射路径、实例类型、父子关系和 Attachment 引用。
4. 手工触发 Gameplay 与 Hero如有场景分别观察近景、远景、移动、转向和多人叠加。
5. 用 MicroProfiler/Stats 检查峰值,并在播放结束后确认实例、连接和循环无残留。
6. 记录无法自动验证的 Asset 权限、发布状态和设备覆盖。
### Studio MCP 可用时
先发现当前服务器实际暴露的能力,再把它用于读取层级、执行验证脚本或截图。不得假定固定工具名,也不得让 MCP 中的临时改动取代源码。工具不可用或调用失败时,回到上述手工路径。
### 客观检查
- [ ] ParticleEmitter 的父级为 `Attachment``BasePart`,且与预期发射区域一致。
- [ ] Burst 模板默认不持续发射;持续模板的 Rate/Lifetime 符合档位。
- [ ] Beam/Trail 的两个 Attachment 有效且未跨越会提前销毁的生命周期。
- [ ] Burst 总数和 `Σ(Rate × Lifetime.Max)` 在预算内。
- [ ] Transparency 末端按需要衰减到 1清理延迟覆盖最大 Lifetime。
- [ ] 距离、大小和朝向匹配玩法范围及摄像机视角。
- [ ] 客户端触发不改变服务端权威的伤害、命中或冷却结果。
- [ ] 停止、取消、重复播放和目标销毁均无残留。
## 现有 authored Prefab 修复
除非用户要求重设计,保留:
- Texture/Flipbook、Lifetime、Speed、SpreadAngle、Size、Transparency、Color
- LightEmission、Brightness、ZOffset、Orientation、Rotation、EmissionDirection
- Shape、Drag、Acceleration、LockedToPart
- 根实例类型、层级、PointLight 与其他视觉子级。
只在有证据时修改安全字段、播放字段、清理字段和项目约定的元数据。把原项目专用 API、属性名或路径当作迁移参考不当作通用标准。
## 交付清单
- 项目探测结果与采用的集成点;
- 分镜时间轴、实例树和参数预算;
- Rojo/项目源码改动;
- 需要用户完成的 Studio 物化或资源发布步骤;
- 自动验证结果、手工验证步骤与尚未覆盖的技术风险。

View File

@@ -0,0 +1,320 @@
# VFX 参数调优与性能档
本文给出初始值和诊断方法。最终值必须结合项目同类效果、Lighting、摄像机距离、最大同屏数量和目标设备实测。
## 1. 常见症状
### 像雾,不像能量
- Size 曲线峰值过晚或末端不收缩。
- Transparency 长时间保持中间值。
- 颜色对比和 LightEmission 不足。
调整顺序:
1. 把 Size 峰值提前到生命周期的 10%30%。
2. 让 Transparency 后半段快速接近 1。
3. 缩短 Lifetime再按场景提高 Brightness/LightEmission。
### 粒子太散
- 缩小 SpreadAngle。
- 收窄 Speed 范围。
- 提高 Drag 让高速粒子快速减速。
- 检查真正问题是否是父级选择:需要集中点发射时应挂 Attachment。
### 没有冲击力
- 第一高光帧使用 Burst不等待持续 Rate 累积。
- 缩短入场 Tween增加短暂 Overshoot。
- 把最大亮度、最大尺寸和主要 Emit 集中在同一高光窗口。
- 命中后留出很短的停顿,再进入衰减。
### 消失有残留或硬切
- Transparency 最后一个 keypoint 通常应为 1。
- 清理时间覆盖所有 PE 的 `Lifetime.Max`、Trail Lifetime 和最后一个 Tween。
- 持续 PE 先禁用,再等待现有粒子消失。
- 检查取消分支是否遗漏 Connection、Tween 或循环任务。
### 颜色脏
- 主色不超过 23 个邻近色。
- 发光粒子通常高 LightEmission、低 LightInfluence。
- 实体烟尘应更多受环境光影响。
- 在项目实际 Lighting 和后处理下观察,不只看属性值。
### 多人同屏卡顿
- 先切 Gameplay 档。
- 减少持续 PE 数、Rate、Lifetime 和透明大面片。
- 移除高频 PointLight。
- 增加距离裁剪和不可见目标停播。
-`最大同屏实例 × 单实例存活粒子` 评估峰值。
### 覆盖范围太小
先区分:
- 起始分布太小:调整直接父 Part 的 Size/Shape。
- 飞行距离太短:调整 Speed、Drag、Lifetime。
- 方向错误:检查 EmissionDirection、SpreadAngle 和父级旋转。
- 只是摄像机读感弱调整纹理、Size 曲线和对比度。
### 几乎看不见
- 检查 Texture/Asset 权限和审核状态。
- 检查 Size、Transparency、Brightness、LightEmission 和 ZOffset。
- 检查是否在预期时刻调用 Emit或持续 PE 是否 Enabled。
- 检查发射方向是否背离主要视角。
- 发光粒子可从 Brightness 510 试起,但不要把固定下限套到烟尘、实体碎屑或特殊 Lighting。
## 2. 曲线模板
### 快闪
```lua
local size = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0.3),
NumberSequenceKeypoint.new(0.15, 1.0),
NumberSequenceKeypoint.new(1, 0),
})
local transparency = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0),
NumberSequenceKeypoint.new(0.2, 0),
NumberSequenceKeypoint.new(1, 1),
})
```
### 烟尘
```lua
local size = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0.2),
NumberSequenceKeypoint.new(0.4, 0.8),
NumberSequenceKeypoint.new(1, 1.2),
})
local transparency = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0.3),
NumberSequenceKeypoint.new(0.6, 0.6),
NumberSequenceKeypoint.new(1, 1),
})
```
### 持续光环
```lua
local size = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0),
NumberSequenceKeypoint.new(0.1, 0.8),
NumberSequenceKeypoint.new(0.9, 0.8),
NumberSequenceKeypoint.new(1, 0),
})
```
## 3. 色板起点
```lua
local fire = ColorSequence.new({
ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 220, 80)),
ColorSequenceKeypoint.new(0.45, Color3.fromRGB(255, 100, 20)),
ColorSequenceKeypoint.new(1, Color3.fromRGB(90, 20, 5)),
})
local frost = ColorSequence.new({
ColorSequenceKeypoint.new(0, Color3.fromRGB(220, 250, 255)),
ColorSequenceKeypoint.new(0.5, Color3.fromRGB(100, 180, 255)),
ColorSequenceKeypoint.new(1, Color3.fromRGB(50, 80, 160)),
})
local holy = ColorSequence.new({
ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 255, 225)),
ColorSequenceKeypoint.new(0.5, Color3.fromRGB(255, 220, 100)),
ColorSequenceKeypoint.new(1, Color3.fromRGB(255, 170, 45)),
})
local shadow = ColorSequence.new({
ColorSequenceKeypoint.new(0, Color3.fromRGB(170, 70, 220)),
ColorSequenceKeypoint.new(0.5, Color3.fromRGB(85, 25, 130)),
ColorSequenceKeypoint.new(1, Color3.fromRGB(25, 5, 35)),
})
```
色板只定义起点还需在场景背景、雾、ColorCorrection 和屏幕曝光下校准。
## 4. 飞行距离与视觉覆盖
忽略 Acceleration 时可粗估:
```text
avgSpeed = (Speed.Min + Speed.Max) / 2
avgLifetime = (Lifetime.Min + Lifetime.Max) / 2
Drag > 0:
distance ≈ avgSpeed / Drag × (1 - exp(-Drag × avgLifetime))
Drag = 0:
distance ≈ avgSpeed × avgLifetime
```
体积发射的视觉半径近似为:
```text
发射体在目标轴向的半尺寸 + 粒子轴向位移
```
校准流程:
1. 从玩法配置读取 Radius、MaxDistance 或目标体型。
2. 计算初始位移,允许先以 ±30% 作为粗筛范围。
3. 加入 Shape、SpreadAngle、Acceleration 和父级旋转影响。
4. 在 Studio 从主要摄像机实测,而不是只看三维距离。
5. 视觉边界可以略超出玩法边界以增强可读性,但不能误导玩家。
常见陷阱:
- 高 Drag 会迅速吃掉 Speed。
- Part 默认 Size 很小,直接挂 Part 的 PE 可能只覆盖局部。
- Attachment 子级 PE 不受 Part.Size 的发射体积影响。
- SpreadAngle 改方向分布,不直接增加总路程。
- `LockedToPart` 会改变移动宿主上的视觉轨迹。
## 5. 性能档位
### 单个瞬发效果
| 指标 | Gameplay | Hero |
|---|---:|---:|
| ParticleEmitter 数量 | ≤ 4 | ≤ 10 |
| Burst 总粒子 | ≤ 30 | ≤ 80 |
| PointLight | 0 | ≤ 1 |
| Beam | ≤ 1 | ≤ 3 |
| 同时活跃 Tween | ≤ 3 | ≤ 6 |
### 单个持续效果
| 指标 | Gameplay | Hero |
|---|---:|---:|
| ParticleEmitter 数量 | ≤ 3 | ≤ 6 |
| 单 PE Rate | ≤ 30/s | ≤ 80/s |
| `Σ(Rate × Lifetime.Max)` | ≤ 20 | ≤ 50 |
| Trail | ≤ 1 | ≤ 2 |
| PointLight | 0 | ≤ 1 |
### 场景级初始预算
移动端初筛可从以下量级开始,再用目标项目和设备实测:
| 指标 | 初始警戒线 |
|---|---:|
| 同时存活粒子估算 | 300 |
| 同时活跃瞬发实例 | 8 |
| 同时活跃持续实例 | 10 |
| 动态 PointLight | 2 |
| 每帧主动 CFrame 更新 | 10 |
这些不是 Roblox 的硬限制。透明大面片、纹理分辨率、过度绘制、Beam 分段和场景复杂度都可能让更低数量也成为瓶颈。
## 6. 层级参数起点
### Gameplay 命中
```text
主层:
Lifetime 0.20.6s
EmitCount 412
Size 峰值 1.54
Brightness 512发光纹理
次层:
Lifetime 0.30.8s
EmitCount 410
Speed 620
点缀:
Lifetime 0.150.5s
EmitCount 26
Size 0.050.5
```
总 EmitCount 仍需 ≤ 30。
### Hero 爆发
```text
主层:
Size 峰值 38
EmitCount 1025
Brightness 820
冲击波:
初始尺寸小
目标直径按玩法范围与摄像机校准
Duration 0.30.7s
点缀:
多层总 Burst 不超过 80
动态光最多 1 个
```
### 持续投射物 / 光环
```text
主体:
Lifetime 0.20.5s
Rate 520/s
必要时 LockedToPart = true
散射:
Lifetime 0.30.8s
Rate 310/s
用 Drag 约束扩散
Trail:
Lifetime 0.20.6s
宽度按摄像机距离校准
```
各 PE 合计仍需满足 Gameplay 存活粒子 ≤ 20。
## 7. 角色尺度基准
标准 R15 的视觉高度通常约 5 studs、肩宽约 2 studs但缩放、Avatar 类型和自定义 Rig 会改变该基准。
角色演出如果需要“包裹感”,效果通常要明显超出轮廓:
- 光柱宽度可从角色宽度 23 倍试起;
- 光柱高度可从角色高度 1.52.5 倍试起;
- 角色周围粒子峰值 Size 可从 36 试起;
- Hero 地面环可从 2040 studs 直径试起;
- 动态光 Range 必须结合目标 Lighting 实测。
Gameplay 命中不应机械套用 Hero 尺寸。
## 8. Tween 节奏
| 场景 | Style | Direction | 常见时长 |
|---|---|---|---:|
| 爆发扩散 | Quad | Out | 0.30.5s |
| 聚合收缩 | Quad | In | 0.20.4s |
| 弹出 | Back | Out | 0.30.5s |
| 呼吸 | Sine | InOut | 1.02.0s |
| 弹性演出 | Elastic | Out | 0.61.0s |
| 渐显 | Quad | Out | 0.10.2s |
| 渐隐 | Quad | In | 0.20.4s |
时长应与分镜高光时刻和输入反馈对齐。Gameplay 高频动作优先响应速度,不要为了平滑拉长到影响手感。
## 9. 调参顺序
1. 核对父级、Shape、方向和资源是否正确。
2. 对齐玩法范围和主要摄像机读感。
3. 调整 Lifetime、Speed、Drag 和发射体 Size。
4. 调整 Size/Transparency 曲线。
5. 调整颜色、亮度、发光与 ZOffset。
6. 在同屏峰值下降档。
7. 最后才增加层数、粒子或动态光。
每次只改一组变量并记录前后值。Studio MCP 可用时可辅助采集属性和截图没有时用项目源码、Explorer 属性和固定摄像机手工对比,不能跳过性能与清理验证。

View File

@@ -0,0 +1,194 @@
# VFX 项目集成:先探测后接入
本文不预设模块名、目录或 API。任何示例接口都只用于说明职责边界必须以目标项目源码为准。
## 1. 建立项目事实
先收集以下证据:
1. **Rojo 映射**:读取 `default.project.json` 及其引用的 project 文件,确定客户端源码、共享模块和资源容器对应的磁盘路径。
2. **模块搜索**:搜索文件名和正文中的 `VFX``Effect``Particle``Beam``Trail``Play``Emit``Stop``Cleanup`
3. **调用方搜索**:从 RemoteEvent、Controller、Component 或状态机追踪真实播放入口,不只看候选模块的名字。
4. **资源搜索**:确认 Prefab 来自源码目录、`.rbxm/.rbxmx`、构建脚本、包、配置,还是只存在于 Place。
5. **生命周期搜索**:查找销毁、断连、取消、去重和目标销毁处理。
6. **相似效果**:找一个触发频率和视觉类型接近的现有实现,记录性能参数和目录约定。
建议输出一份简短探测记录:
```text
Rojo project:
客户端入口:
候选 VFX 模块:
真实公开函数:
Prefab/资源位置:
现有清理策略:
同类效果:
待确认:
```
只有源码、映射和实际 Place 内容能够证明项目能力。文档、历史名称或其他仓库中的同名模块不能单独作为依据。
## 2. 选择集成路线
### 路线 A已有通用播放系统
适用条件:
- 有稳定调用方;
- 能读取模板或构造实例;
- 支持所需的跟随/世界坐标行为;
- 有明确停止和清理路径。
做法:
1. 阅读真实函数签名和类型定义。
2. 确认模板查找规则、重复播放语义和错误行为。
3. 按现有配置/资源格式新增效果。
4. 复用项目现有触发点,不在 Controller 中重复实现已有生命周期逻辑。
### 路线 B系统存在但能力不足
先写缺口矩阵:
| 能力 | 需求 | 现有支持 | 证据 |
|---|---|---|---|
| Burst/持续 | | | |
| target/position | | | |
| 朝向/跟随 | | | |
| 停止/取消 | | | |
| 自动清理 | | | |
| 质量档位 | | | |
只有在组合现有 API 仍无法满足时才扩展。扩展应:
- 对多个效果可复用;
- 保持原模块的命名、类型和错误处理风格;
- 同时实现取消和目标销毁路径;
- 带最小测试或可重复验证脚本;
- 不为一个效果引入平行注册表。
### 路线 C没有通用系统
不要伪造项目注册表。可以实现一个最小客户端模块,其职责仅包括:
- 从项目已确定的资源位置克隆模板,或调用效果构造函数;
- 绑定 target/position
- 触发 Burst 或启停持续组件;
- 返回可停止句柄;
- 在完成、取消和目标销毁时清理。
示意接口(**不是目标项目既有 API**
```lua
export type EffectHandle = {
Stop: (self: EffectHandle, immediate: boolean?) -> (),
}
local Effects = {}
function Effects.play(effectName: string, options): EffectHandle
-- 示例职责:查找/构造、挂载、播放、返回清理句柄
error("按目标项目结构实现")
end
return Effects
```
复制示例前必须改成项目真实命名和类型约定。
### 路线 DPrefab 只存在于 Studio
Place 内资产不是可审查的唯一真源。至少选择一种可复现方式:
- 项目已采用的 `.rbxm/.rbxmx` 文件;
- 受版本控制的构建 ModuleScript/工具脚本;
- 可生成实例树的配置数据;
- 明确到实例、父级和属性的 Studio 手工清单。
不要把一次 Studio 临时创建当作完成,也不要在没有备份时替换唯一 authored Prefab。
## 3. 客户端/服务端边界
推荐边界:
| 职责 | 位置 |
|---|---|
| 命中、伤害、冷却、合法目标 | 服务端权威逻辑 |
| 触发最小事件数据 | 服务端 → 客户端 |
| 粒子、Beam、Trail、Tween、镜头内视觉 | 客户端 |
| 本地性能档和距离裁剪 | 客户端 |
客户端收到远程参数后仍应校验类型和范围,避免异常数据制造大量实例。视觉结果不得反向决定命中或伤害。
## 4. Prefab 与运行时约定
### 瞬发
- 模板中的瞬发 `ParticleEmitter` 通常 `Enabled = false`
- 发射数量来自项目配置、Prefab attribute 或调用参数,选择现有约定之一。
- 清理时间至少覆盖最大 Lifetime 和最后一个 Tween/延迟事件。
### 持续
- 只对明确需要持续发射的层设置 `Enabled = true`
- `Σ(Rate × Lifetime.Max)` 必须满足所选性能档。
- Stop 时先禁用发射器,再决定柔和等待或立即销毁。
- 重复 Play 的行为必须明确:复用、重启、叠加或拒绝。
### 跟随与朝向
区分三种需求:
1. 跟随位置和旋转;
2. 只跟随位置,保持触发瞬间朝向;
3. 固定世界位置。
近战弧线常需要第 2 种。可在触发时保存世界偏移和旋转:
```lua
local start = root.CFrame * CFrame.new(localOffset)
local lockedRotation = start - start.Position
local lockedWorldOffset = start.Position - root.Position
-- 在效果生命周期内更新
holder.CFrame = CFrame.new(root.Position + lockedWorldOffset) * lockedRotation
```
保存帧更新连接,并在效果结束、取消或 root 销毁时断开。
## 5. Rojo 与 Studio 手工路径
当没有 Studio MCP 时:
1. 按项目 README/脚本启动其声明版本的 Rojo 服务。
2. 在 Studio 打开正确 Place用 Rojo 插件连接对应 project。
3. 等待同步后,在 Explorer 核对脚本和资源容器映射。
4. 如果实例树不能由源码直接表达,按交付清单创建根 Part/Model、Attachment 和组件。
5. 逐项填写属性,特别检查 `Parent``Attachment0/1``Enabled``Rate``Lifetime``Transparency` 和资源 ID。
6. 将可导出的模型保存到项目采用的文件格式;不能导出时保留构建脚本和手工清单。
7. 在 Play 模式通过真实游戏入口触发,不只在 Explorer 中手动启用组件。
8. 停止后检查残留实例、连接和任务;再测试重复播放和目标中途销毁。
Studio MCP 可用时,可辅助读取层级、执行验证和截图。先确认当前服务器实际支持的能力;所有持久修改仍需回写源码或项目资源文件。
## 6. 原项目 API 示例的使用规则
历史资料中出现的播放模块、Controller、注册表、路径 API 或 Attribute 名称都属于**原项目示例**。迁移时:
- 搜到同名实现并核对签名后才能沿用;
- 只搜到调用但没有实现时,视为不完整依赖;
- 完全搜不到时,按路线 C/D 处理;
- 不为了匹配示例而创建一整套同名架构;
- 文档中的数字参数可作为初始调参参考,但要重新计算当前项目的同屏预算。
## 7. 集成验收
- [ ] 探测记录包含真实路径和函数签名。
- [ ] 新效果位于正确的 Rojo 映射或项目资源格式中。
- [ ] 客户端/服务端职责没有倒置。
- [ ] Burst、持续、重复播放、停止和目标销毁语义明确。
- [ ] ParticleEmitter 的父级与发射区域需求一致。
- [ ] Attachment 引用和生命周期有效。
- [ ] 无 Studio MCP 时仍有完整的构建与验证步骤。
- [ ] Studio MCP 改动没有成为唯一真源。
- [ ] 所有借用的原项目接口都已标注并由当前源码验证。

View File

@@ -0,0 +1,212 @@
# Roblox VFX 组件参考
组件选择应服从发射区域、运动方式、摄像机视角和性能预算,不服从某个固定项目 API。
## ParticleEmitter
适合火花、烟雾、碎片、能量、爆裂、充能和环境粒子。
### 合法父级与发射区域
`ParticleEmitter` 可以是 `Attachment``BasePart` 的直接子级:
| 父级 | 发射区域 | 适用场景 |
|---|---|---|
| `Attachment` | Attachment 所在点 | 枪口、手心、脚底、关节、精确命中点 |
| `BasePart` | Part 对应的形状/体积或表面 | 全身状态、区域雾、体积火花、范围粒子 |
关键事实:
- Part 的 Size 不会扩大其 Attachment 子级 PE 的发射区域。
- 体积发射要把 PE 直接放在合法 BasePart 下,并检查 `Shape``ShapeStyle``ShapeInOut``ShapePartial`
- Attachment 本身必须放在 Roblox 支持的合法宿主下;不能把它直接挂在 Folder/Model 后期待正常空间行为。
### 关键参数
| 参数 | 用途 |
|---|---|
| `Rate` | 每秒持续发射数;需结合 Lifetime 计算存活量 |
| `Emit(count)` | 一次性 Burst适合命中和技能爆点 |
| `Lifetime` | 单粒子存活时间 |
| `Speed` / `Drag` | 初速度与减速 |
| `Acceleration` | 持续加速度,如重力或上升 |
| `SpreadAngle` | 发射方向散布,不会增加总飞行距离 |
| `Size` | 生命周期内尺寸曲线 |
| `Transparency` | 生命周期内透明曲线,通常以 1 结束 |
| `Color` | 颜色渐变 |
| `Rotation` / `RotSpeed` | 粒子面片自转 |
| `LightEmission` / `LightInfluence` | 发光混合与环境光影响 |
| `Brightness` | 粒子亮度;应结合纹理和场景曝光校准 |
| `Orientation` | 面向摄像机或沿速度方向的渲染方式 |
| `EmissionDirection` | 相对父级的发射方向 |
| `LockedToPart` | 粒子是否随父级继续移动 |
| `ZOffset` | 渲染排序偏移,过大可能穿透遮挡 |
### Burst 模板
```lua
emitter.Enabled = false
emitter.Rate = 0
emitter:Emit(config.EmitCount)
```
### 持续模板
```lua
emitter.Enabled = true
emitter.Rate = config.Rate
-- 停止时保留已经发出的粒子
emitter.Enabled = false
task.delay(emitter.Lifetime.Max, function()
if root.Parent then
root:Destroy()
end
end)
```
目标被立即销毁、场景卸载或用户要求硬取消时可以走立即清理分支。
## Beam
适合激光、电流、链接、瞄准线和光柱。
- 必须设置有效 `Attachment0``Attachment1`
- 两个 Attachment 可以位于不同 BasePart但都必须覆盖整个 Beam 生命周期。
- `Segments` 越高曲线越平滑,开销也越高;直线不需要高分段。
- `Width0/Width1``Transparency``TextureLength``TextureSpeed` 共同决定可读性。
- 对 authored Beam 不要未经观察就覆盖宽度、纹理或 Attachment。
```lua
beam.Attachment0 = startAttachment
beam.Attachment1 = endAttachment
beam.Width0 = 0.5
beam.Width1 = 0.2
beam.Segments = 10
```
## Trail
适合武器轨迹、移动拖尾和速度感。
- 需要两个有效 Attachment 定义带状面。
- 只有 Attachment 发生空间移动才会形成轨迹。
- `Lifetime` 决定尾迹长度;`MinLength` 可抑制低速抖动。
- 停止时先 `Enabled = false`,按需要等待 Lifetime 后销毁宿主。
- 验证角色快速转向和瞬移,避免尾迹跨越不合理距离。
## Highlight
适合目标描边、受击闪烁和状态强调。
```lua
local highlight = Instance.new("Highlight")
highlight.FillColor = Color3.new(1, 1, 1)
highlight.FillTransparency = 0.5
highlight.OutlineTransparency = 1
highlight.Adornee = target
highlight.Parent = target
```
- `AlwaysOnTop` 可读性强,但会穿墙显示;竞技或隐蔽场景需谨慎。
- 短闪必须在取消和目标销毁时清理。
- 长期状态应复用实例,避免每帧创建。
## Decal、SurfaceGui、BillboardGui
### Decal
- 适合静态或简单渐隐的表面图案。
- 检查 Face、透明背景和表面方向。
### SurfaceGui
- 适合需要 UI 层级、旋转/缩放的表面图案。
- 黑底加法粒子纹理通常不适合直接改用 SurfaceGui因为普通 UI 混合会显示黑底。
- 先确认项目确有对应运行时封装;没有时使用普通 Roblox 实例与清晰清理逻辑。
### BillboardGui
- 始终面向摄像机,适合头顶标记或状态符号。
-`MaxDistance` 和客户端裁剪控制远距离开销。
- 伤害数字和复杂 HUD 通常属于 UI 系统,而非本技能范围。
## TweenService
适合控制几何体尺寸、透明度、光强、颜色和位置节奏。
```lua
local tween = TweenService:Create(
ring,
TweenInfo.new(0.5, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
{
Size = Vector3.new(0.2, 30, 30),
Transparency = 1,
}
)
tween:Play()
```
保存需要取消的 Tween效果提前终止时调用 `Cancel()` 并清理对象。
常用节奏:
| 场景 | Easing |
|---|---|
| 爆发扩散 | Quad/Out |
| 聚合吸收 | Quad/In |
| 弹出 | Back/Out |
| 呼吸 | Sine/InOut |
| 夸张回弹 | Elastic/Out |
## PointLight / SpotLight
- 高频 Gameplay 效果默认不使用动态光。
- Hero 演出通常最多 1 个动态光,仍需按同屏数量评估。
- `Brightness``Range` 必须在目标 Lighting/曝光下实测。
- `Shadows = false` 通常更适合短时特效。
## Flipbook 与斩击平面
先根据图案语义判断渲染平面。
水平俯视挥砍图案可尝试:
```lua
emitter.Orientation = Enum.ParticleOrientation.VelocityPerpendicular
emitter.EmissionDirection = Enum.NormalId.Top
emitter.SpreadAngle = Vector2.new(0, 0)
emitter.Speed = NumberRange.new(0.001, 0.001)
emitter.LockedToPart = true
emitter.RotSpeed = NumberRange.new(0, 0)
emitter.ZOffset = 0.05
```
`Rotation` 用于调 atlas 在平面内的方向。屏幕朝向的新月或竖斩才考虑 billboard/camera-facing 表现。
验证必须覆盖:
- 顶视、侧视和玩家常用摄像机;
- 静止、移动、急转和攻击中断;
- `Orientation``EmissionDirection``Rotation``Speed``ZOffset`
- Flipbook 帧率、起始帧和纹理透明度。
## 纹理与资源
| 用途 | 常见尺寸 | 注意 |
|---|---:|---|
| 粒子 | 128×128 / 256×256 | 边缘透明、留出 mip 空间 |
| 法阵 | 512×512 / 1024×1024 | 圆形构图、透明背景 |
| Beam/Trail | 256×64 / 512×128 | 可平铺、边缘衔接 |
| Flipbook | 由布局决定 | 每格尺寸一致,避免采样串格 |
新资源的生成、上传、所有权和审核是独立步骤。先复用项目已授权资源;必须新增时,说明来源、许可、上传账号和待填 AssetId不在逻辑代码中散落硬编码。
## 组件选择检查
- [ ] PE 父级与发射区域一致。
- [ ] Beam/Trail Attachment 引用和生命周期有效。
- [ ] 组件组合能从主要摄像机读清主次。
- [ ] 持续量按 `Rate × Lifetime.Max` 估算。
- [ ] 所有 Tween、连接和实例都有取消/销毁路径。
- [ ] 自定义资源的来源、许可、权限和发布状态明确。

View File

@@ -0,0 +1,144 @@
# 常见 VFX 组合模式
以下模式是组件与节奏参考,不是项目 API。接入前必须先完成源码探测并改用目标项目真实的模块、目录和触发方式。
## A. 贴地法阵 / 技能圈
| 层 | 组件 | 作用 |
|---|---|---|
| 主层 | Decal 或 SurfaceGui | 法阵底纹 |
| 次层 | 向上 ParticleEmitter | 能量碎粒 |
| 点缀 | 反向旋转外圈 | 增加层次 |
| 收尾 | 网格/环形 Part + Tween | 冲击波扩散 |
示例结构:
```text
GroundCircle (Model)
├─ Base (Part, Anchored, thin)
│ ├─ Decal / SurfaceGui
│ └─ Center (Attachment)
│ └─ UpwardParticles (ParticleEmitter)
├─ OuterRing (Part)
└─ Shockwave (Part or MeshPart)
```
示例时间轴:
```text
0.00s 主图案快速出现
0.05s 外圈反向旋转
0.08s 向上 Burst
0.60s 主图案开始衰减
0.85s 冲击波扩散
1.00s 断开更新并清理
```
如果使用逐帧旋转,保存并断开 RunService 连接。项目已有旋转组件时优先复用,但先验证其接口和清理行为。
## B. 投射物 / 激光 / 斩击
| 层 | 组件 | 作用 |
|---|---|---|
| 主体 | Beam、MeshPart 或粒子 | 主轮廓 |
| 运动 | Trail | 速度与路径 |
| 命中 | Burst PE | 命中读感 |
| 点缀 | 短 Highlight | 目标反馈 |
投射物结构:
```text
Projectile (Part)
├─ Front (Attachment)
│ └─ Glow (ParticleEmitter, continuous)
├─ Back (Attachment)
├─ MotionTrail (Trail)
└─ optional PointLight
```
命中效果应与服务端命中判定分离:服务端决定合法命中,客户端只在收到事件后播放。视觉插值不得反向成为伤害依据。
近战斩击要明确:
- 图案是水平扫击还是竖直 billboard
- 朝向是否锁定在攻击开始瞬间;
- 效果是跟随位置、跟随完整 CFrame还是世界坐标固定
- 快速转向时是否会绕角色产生错误横移。
## C. 充能 → 释放 → 命中
| 阶段 | 组件 |
|---|---|
| 充能 | Inward ParticleEmitter、低成本光照 |
| 释放 | Beam/投射物/Trail |
| 命中 | Burst + 短 Tween |
Inward 粒子初始参数:
```lua
emitter.Shape = Enum.ParticleEmitterShape.Sphere
emitter.ShapeInOut = Enum.ParticleEmitterShapeInOut.Inward
emitter.Speed = NumberRange.new(3, 6)
```
取消施法时必须停止充能发射、断开更新并清理;不能只覆盖正常释放路径。
## D. 跟随光环 / 常驻状态
常驻效果预算必须比一次性效果更严格:
- Gameplay PE 数通常 ≤ 3
- 总存活粒子 `Σ(Rate × Lifetime.Max)` ≤ 20
- 默认不使用动态光;
- 远距离或不可见目标可降级/停播;
- 周期点缀优先低频 Burst不使用高 Rate 保持热闹。
重复播放语义需要明确。状态刷新时通常更新时长或复用现有句柄,不应无限叠加实例。
## E. 冲击波 / 范围爆发
| 层 | 组件 | 作用 |
|---|---|---|
| 主层 | 环形 MeshPart 或 Part + Tween | 范围读感 |
| 次层 | 向外 Burst | 能量/碎屑 |
| 点缀 | 地面闪光 | 中心强调 |
如果使用 Cylinder Part先在 Studio 确认轴向和材质轮廓;普通实心 Cylinder 不一定表现为“环”,项目有环形 Mesh 时优先复用。
视觉半径应匹配玩法 Radius但不要让视觉环承担命中检测。
## F. 护盾 / 增益 / 减益
| 层 | 组件 |
|---|---|
| 主层 | 半透明 Mesh/Part |
| 表面 | 直接挂 BasePart 的体积/表面 PE |
| 点缀 | Highlight 或低频 Burst |
状态持续时间长,必须处理:
- 状态刷新;
- 多来源叠加;
- 角色重生/销毁;
- 阵营或可见性变化;
- 服务端状态先结束但客户端事件延迟。
## G. 恢复 authored Prefab
修复或迁移既有视觉资产时:
1. 找到参考 Prefab 和运行时 Prefab先比较根类型、层级和组件数量。
2. 保留 Texture/Flipbook、曲线、颜色、速度、形状、朝向和 authored enabled 状态。
3. 只改运行时安全、Burst/持续模式、元数据和清理字段。
4. Burst 转换前记录原 `Enabled`/`Rate`,确保可回滚。
5. 音频交给项目音频系统,不把 Sound 生命周期塞进通用 VFX 模块。
6. 验证贴图 ID、Flipbook、PE 数量、Burst 数量、Beam/Trail Attachment 和残留实例。
## 组合设计检查
- [ ] 主层能在目标摄像机和背景中读清。
- [ ] 次层与点缀不会抢主层。
- [ ] 高光时刻与玩法事件一致,但不承担玩法判定。
- [ ] 发射区域、跟随方式和朝向均已明确。
- [ ] 正常结束、取消、重复播放和目标销毁都有路径。
- [ ] 采用的播放 API 已由当前项目源码验证,而非来自本文示例。

View File

@@ -0,0 +1,55 @@
---
name: roblox-vibe-project-framework
description: 在 Roblox 项目规划已确认后,按小型垂直切片实现或重构 Luau/roblox-ts 功能。用户要求依据已批准架构落地功能、扩展现有系统或分阶段实现完整系统时使用;先读取目标仓库架构和工具,不默认安装依赖。
---
# Roblox 切片实现框架
本 Skill 负责把已经确认的规划转化为可验证的小型实现切片。若架构、数据所有权、依赖选择或破坏性迁移尚未确认,先返回规划阶段,不直接生成完整系统。
## 开始前
1. 读取目标仓库的 README、架构文档、Rojo 映射、依赖/锁文件、工具版本、验证入口、目录和相邻实现。
2. 找到项目已有的 Service、Controller、共享类型、Remote 契约、数据服务、清理器和 UI 模式,优先复用。
3. 核对已批准规划、当前里程碑与验收标准;发现规划和仓库现状冲突时先说明。
4. 确认服务端权威边界、数据迁移、Remote 验证与回滚风险后再编辑。
## 切片流程
1. 用一句话定义用户可见目标。
2. 列出服务端职责、客户端职责、共享契约、数据变化和安全约束。
3. 列出本切片需要创建或修改的最少文件。
4. 只实现能独立验证的第一块,不一次生成所有未来系统。
5. 模块保持单一职责;新 Luau 模块在项目约定允许时使用 `--!strict`
6. 更新因实现而失效的相关项目文档;若无需更新,在交付中说明。
7. 使用仓库已有验证入口检查切片,报告剩余 stub、风险和下一切片。
## 硬约束
- 客户端只提交意图,服务端决定货币、物品、伤害、任务、购买、交易与进度结果。
- Remote 契约集中维护,包含类型、验证、权限、限流和必要的幂等策略。
- 持久化写入通过专用数据边界,不能由任意玩法模块直接修改原始 profile。
- Bootstrap 只装配生命周期;不创建承载全部逻辑的巨型脚本。
- 服务端秘密与专用逻辑不能放入客户端可读取的位置。
- 所有连接、实例、任务、Promise 或订阅都要有明确清理策略。
- 不虚构 Roblox API不绕开项目既有架构和工具。
## 依赖治理
- 新增、下载、安装或升级任何依赖必须遵循项目的 `dependency-governance` Rule。
- 先证明现有能力不能满足需求,再说明候选名称、用途、版本、来源、许可、维护状态、影响范围与替代方案。
- 未获得用户明确确认前,不安装、不下载、不修改依赖清单或锁文件。
- 用户未确认时,采用无新增依赖的实现或停止在明确的待确认点。
## References
- 分层和决策边界:[architecture.md](references/architecture.md)
- 目标结构与启动入口:[project-structure.md](references/project-structure.md)
- 切片流程:[vibe-coding-workflow.md](references/vibe-coding-workflow.md)
- 工具和依赖:[tools-and-libraries.md](references/tools-and-libraries.md)
- 服务端权威与安全:[server-authority-security.md](references/server-authority-security.md)
- 数据与经济:[data-and-economy.md](references/data-and-economy.md)
- 网络契约:[networking.md](references/networking.md)
- 输出格式:[output-templates.md](references/output-templates.md)
按任务读取最少的 reference。

View File

@@ -0,0 +1,40 @@
# 架构准则
## 先适配目标仓库
先读取目标仓库的架构、映射、入口与相邻模块。以下分层是判断依据,不是强制目录名;已有清晰架构应优先保留。
## 运行时分层
- `Shared`:客户端和服务端都可安全使用的类型、常量、配置、纯函数、验证器与网络契约。
- `Server`:权威规则、持久化、经济、战斗结算、购买、匹配、管理和反滥用。
- `Client`UI、输入、相机、效果、动画、本地表现与必要的预测。
- 服务端专用容器:不应复制给客户端的模板、资产和敏感逻辑。
## Service / Controller
- Service 持有服务端状态与业务规则。
- Controller 持有客户端表现、输入和本地编排。
- Shared 模块保持无特权、无秘密,并避免反向依赖运行时专用模块。
- Bootstrap 只注册、排序和启动模块。
## 实现前的决策
- Luau 或 roblox-ts
- 单 Place 或多 Place
- 现有框架或无框架
- 持久化和迁移边界
- 网络契约与限流
- UI 与状态管理
- 对象/服务模型或 ECS
这些决策应来自已确认规划和仓库现状。涉及新增依赖时必须再次遵循 `dependency-governance`,不能把规划中的候选视为安装授权。
## 避免
- 巨型管理器与循环依赖
- 服务端模块依赖客户端模块
- UI 脚本包含权威业务规则
- 任意模块直接修改 profile
- Remote 名称和负载散落各处
- 为第二个用例尚不存在的场景提前抽象

View File

@@ -0,0 +1,42 @@
# 数据与经济
## 数据服务边界
所有持久化数据通过专用服务访问。玩法模块调用领域方法,不直接修改原始 profile 表,例如:
```lua
DataService:AddCurrency(player, currencyType, amount, reason, idempotencyKey)
DataService:GrantItem(player, itemId, reason, idempotencyKey)
```
方法名称和签名必须适配目标仓库,不要机械复制示例。
## Schema
持久化切片需要考虑:
- `SchemaVersion`
- 默认结构
- 从旧版本迁移
- 会话锁
- 自动保存、离开保存和安全停服
- 加载失败与损坏数据处理
- 开发、测试和生产环境隔离
新增字段前先检查现有 schema、迁移入口和调用者。破坏性迁移必须在实现前取得确认并准备回滚或兼容读取方案。
## 经济与购买
- 所有货币、库存、奖励、交易和购买结果由服务端决定。
- 奖励携带原因与稳定幂等键,重试不得重复发放。
- 回执处理可安全重放。
- 交易使用双方确认、服务端状态校验和防重复提交。
- 客户端提交的 ID、数量和价格必须对照服务端配置验证。
- 按项目政策保留必要审计信息。
## 切片验证
- 覆盖重复请求、重连、加载失败和迁移路径。
- 检查同一操作重复执行是否产生重复收益。
- 确认客户端无法直接写入权威数据。
- 如果测试依赖新库,先遵循 `dependency-governance` 并取得确认;否则使用现有测试能力。

View File

@@ -0,0 +1,40 @@
# 网络契约
## 组织原则
先查找项目已有 Remote 注册、类型、验证器和限流模块。若已有集中协议层,所有新请求都扩展该层,不创建平行体系。
协议层至少表达:
- Remote 名称和方向
- 请求与响应类型
- 服务端验证
- 权限与所有权
- 频率限制
- 失败语义
- 需要时的幂等或防重放规则
## 命名
名称表达客户端请求的意图,例如 `RequestEquipItem``RequestUseAbility`。避免 `GiveCoins``DealDamage``GrantItem` 这类暗示客户端决定结果的名称。
## 契约示意
```lua
-- 请求RequestEquipItem
-- 方向Client -> Server
-- 负载:{ itemId: string }
-- 服务端验证:资料已加载、物品归属、可装备状态、冷却/限流
-- 结果:更新权威状态,再复制必要表现数据
```
## 限流与异常
- 按玩家和动作限流。
- 校验状态、目标、距离与权限。
- 拒绝未知字段、非法类型或超出范围的值。
- 对持续无效请求按项目策略记录或短时忽略。
## 高频网络
先通过目标仓库现有工具测量频率与负载。只有数据证明原生 Remote 和现有协议层不足时,才提出额外网络库;任何候选必须遵循 `dependency-governance`,未确认前不安装。

View File

@@ -0,0 +1,80 @@
# 输出模板
## 功能切片
```markdown
# 功能切片:[名称]
## 已确认规划
- 目标:
- 验收标准:
## 仓库现状
- 现有架构:
- 可复用模块:
- 现有验证入口:
## 职责
- Server
- Client
- Shared
- 数据变化:
- 网络变化:
## 文件范围
- 创建:
- 修改:
## 实现
[仅包含本切片]
## 验证
- [ ] ...
## 剩余风险与下一切片
- ...
```
## 分阶段系统实现
```markdown
## 里程碑
1. [第一个可运行垂直切片]
2. [扩展]
3. [加固与发布]
## 当前只实施
- [里程碑 1 的范围]
## 暂不实施
- [后续范围及原因]
```
## 架构冲突报告
```markdown
## 发现的冲突
- 已批准规划:
- 仓库现状:
- 影响:
## 建议
- 最小调整:
- 是否需要重新确认:
- 是否涉及新增依赖或破坏性迁移:
```
## 依赖确认项
```markdown
## 待确认依赖
- 名称与版本:
- 用途:
- 来源与许可:
- 维护状态:
- 影响范围:
- 仓库现有替代:
- 无新增依赖方案:
```
没有明确确认时,不执行依赖变更。

View File

@@ -0,0 +1,54 @@
# 项目结构
## 适配原则
以下只是职责示意。先读取目标仓库的 Rojo 映射、现有源目录、命名约定和启动入口,再把切片放到对应位置;不要为了套模板搬迁健康的现有结构。
```text
src/
ReplicatedStorage/
Shared/
Config/
Types/
Network/
Util/
ServerScriptService/
Server/
Bootstrap.server.luau
Services/
Systems/
Data/
StarterPlayer/
StarterPlayerScripts/
Client/
Bootstrap.client.luau
Controllers/
UI/
Input/
Camera/
Effects/
ServerStorage/
ServerAssets/
tests/
```
## 文件约定
- 文件扩展名和后缀遵循项目现有约定。
- Shared 模块不能依赖服务端专用代码。
- 权威业务逻辑不能放入客户端可读取位置。
- 功能优先按领域和生命周期归属,不只按技术类型归档。
## Bootstrap
客户端与服务端各保留清晰启动入口。启动入口负责发现、注册、排序和启动组件,不包含功能细节。
## 切片放置检查
1. 谁拥有权威状态?
2. 谁需要读取该模块?
3. 生命周期由谁创建和清理?
4. 是否已有同职责模块?
5. 新文件是否引入逆向依赖或循环依赖?
涉及目录重构时先检查所有调用方并保持公共 API不要在功能切片中顺带进行无关的大规模迁移。

View File

@@ -0,0 +1,42 @@
# Server Authority 与安全
## 核心规则
客户端发送意图,服务端决定结果。
客户端可以请求交互、装备、施放、目标选择、购买流程或本地 UI不能决定货币、库存、伤害、任务完成、购买履约、交易结果、冷却绕过、传送或管理操作。
## Remote 验证
每个 Remote 明确定义:
- 参数形状与范围
- 服务端状态前置条件
- 身份、权限和所有权
- 目标与距离有效性
- 冷却或速率限制
- 需要时的幂等处理
- 明确失败结果
- 可疑输入记录策略
## 敏感内容
以下内容不能放在客户端可读取位置:
- API key、token 和私密凭据
- 服务端授权表
- 可导致利用风险的敏感经济规则
- 发奖和回执处理逻辑
- 服务端专用资产或模板
## 管理与调试
- 命令实现保持服务端所有。
- 服务端按 UserId、群组角色或项目政策授权。
- 开发、测试与生产采用环境限制。
- 发奖、库存、经济、审核、传送和 LiveOps 操作需要额外检查和日志。
- 客户端入口隐藏不构成安全边界。
## 第三方内容
外部模型、脚本、插件和包在审查前均视为不可信。引入任何外部内容前必须遵循 `dependency-governance` 并取得用户确认。

View File

@@ -0,0 +1,46 @@
# 工具与依赖
## 先读取现状
实现前检查目标仓库的:
- 依赖清单和锁文件
- 工具版本声明
- Rojo 映射
- 格式化、lint、类型分析和测试入口
- 已有框架、内部包与通用模块
已有工具和能力优先,不改用模板偏好的替代品。
## 常见候选
Rojo、Wally、StyLua、Selene、Luau 类型分析、测试框架、数据 profile 库、清理器、异步库、命令系统、UI 或网络框架都只是可能的候选,不是默认安装项。
## `dependency-governance`
任何新增、下载、安装或升级必须:
1. 证明现有能力不足。
2. 说明名称、用途、目标版本或选择依据。
3. 核对来源、许可与维护状态。
4. 说明影响范围、迁移成本和仓库内替代方案。
5. 获得用户明确确认。
确认前不得修改依赖清单、锁文件或工具版本,不得静默下载。
## 选择原则
- 简单项目优先无新框架。
- 只为真实重复需求引入通用库。
- 不同时引入功能重叠的清理、状态、UI 或网络方案。
- ECS 仅在实体规模和组合式系统复杂度证明需要时考虑。
- 高频网络库必须由测量结果驱动,不能代替协议集中化和服务端验证。
- 项目已有固定验证入口时使用该入口,不自行拼装另一套工具链。
## 无新增依赖路径
用户未确认候选时:
- 使用 Roblox 原生 API 和仓库现有模块完成切片;或
- 缩小切片并明确阻塞点;或
- 只提交接口与计划,不伪造依赖已可用。

View File

@@ -0,0 +1,51 @@
# 切片实现流程
## 前置条件
- 项目规划已经确认。
- 当前切片目标和验收标准明确。
- 已读取目标仓库架构、工具、依赖、验证入口和相邻实现。
- 安全敏感或破坏性选择已有明确决定。
前置条件不满足时回到规划,不直接生成完整系统。
## 默认流程
1. 定义一个用户可见行为。
2. 定义权威服务端职责。
3. 定义客户端输入与表现职责。
4. 定义共享类型、配置和网络契约。
5. 定义数据 schema 与迁移变化。
6. 列出最少的创建/修改文件。
7. 实现该垂直切片。
8. 更新因实现失效的相关文档。
9. 使用仓库现有能力格式化、静态检查、类型分析和测试。
10. 报告剩余 stub、Studio 验证项、风险和下一切片。
## 假设
- 信息不完整时只做安全、可逆且不改变核心架构的假设,并明确写出。
- 涉及数据丢失、安全边界、付费、破坏性迁移或依赖引入时必须停止并确认。
- 不以“快速”为理由绕过服务端权威、Remote 验证或清理。
## 代码规则
- 生成小而聚焦的模块。
- 采用项目现有命名和组织方式。
- 新 Luau 模块在项目约定允许时使用 `--!strict`
- 类型化跨模块 API 与 Remote 负载。
- 调优值进入项目已有配置边界。
- 抽象在出现真实第二用例后再建立。
- 不虚构 Roblox API 或仓库不存在的模块。
## 验证回路
交付前确认:
- 实际创建和修改了什么
- 服务端权威检查如何落实
- Remote 如何验证与限流
- 数据迁移或幂等如何处理
- 哪些检查已运行,哪些受环境限制
- Studio 中仍需验证什么
- 下一切片是什么

View File

@@ -2,3 +2,7 @@
按作者聚合的贡献概览,便于多人协作时区分提交来源。
> 本文件由 `tools/changelog_build.py` 自动生成,请勿手改。
## nostars (Nostars Developer) — 2 条
- [CL-20260727-1322-nostars] 2026-07-27 — 安全接入 Roblox 项目类型 Bundle
- [CL-20260727-1213-nostars] 2026-07-27 — 建立多项目类型 Cursor 初始化架构

View File

@@ -4,3 +4,83 @@
> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改;新增/修改请编辑 `entries/<author>/<id>.md` 后重跑生成器。
## 记录
### [CL-20260727-1322-nostars] 2026-07-27 13:22 — 安全接入 Roblox 项目类型 Bundle
- **merge_source**: FOR_Roblox_Main
- **author**: nostars (Nostars Developer)
- **type**: feat
- **tags**: cursor-init, roblox, skills, local-env, open-cloud, security
- **affected_files**:
- .cursor/bootstrap/catalog.json
- .cursor/bootstrap/skills.json
- .cursor/bootstrap/tools/cursor_init.py
- .cursor/bootstrap/tests/test_cursor_init.py
- .cursor/bootstrap/types/roblox/manifest.json
- .cursor/bootstrap/types/roblox/fragments/roblox.gitignore
- .cursor/bootstrap/types/roblox/fragments/roblox.gitattributes
- .cursor/bootstrap/types/roblox/payload/rules/project/roblox-luau-guardrails.mdc
- .cursor/bootstrap/types/roblox/payload/skills/roblox-project-initializer/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-vibe-project-framework/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-ui-producer/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/building-3d-objects/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/building-maps/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-camera-cinematics/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-vfx-designer/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-animation-producer/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-commonkit-usage/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-asset-uploader/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-asset-uploader/scripts/upload.py
- .cursor/bootstrap/types/roblox/payload/skills/roblox-asset-uploader/scripts/test_upload.py
- .cursor/skills/cursor-init/SKILL.md
- .cursor/skills/cursor-init/reference.md
- .cursor/hooks/tests/test_check_changelog.py
- .cursor/pitfalls/pitfalls.md
- **what**: 建立 Roblox 自包含 payload、manifest、Registry 元数据、扫描/Git 片段与类型专属本机字段;去除 Codex 配置、个人路径、项目 API 残留,并合并 Guardrails、Design Consultant 与 Retrospective 的有效能力。
- **why**: Roblox 候选来自特定 Codex/SwordnWizard 环境,原样接入会提前加载错误 Skill、泄漏环境假设并让 API key 和外部上传缺少安全边界。
- **decisions**: Roblox 选择时才物化十个 SkillCommonKit 与 Asset Uploader 保持条件触发Guardrails 转 always-on 项目 Rule敏感字段禁止 `--set` 且不参与 drift不保存密码、Cookie 或会话令牌;上传器不新增第三方依赖。
- **tests**: 初始化 18 项、Hook 7 项、上传器 6 项测试通过;真实 payload 在临时目标完成 Roblox → General → Godot → Roblox 切换并逐段 `--check`;三个类型实仓 dry-run 无 blocker敏感 key 命令行输入被拒绝且轮换不改变计划摘要IDE lint 无错误。
- **notes**: 验证成功后删除 `roblox_skills_waitingforprocessing``.cursor/bootstrap/types/roblox/payload` 成为唯一 canonical。另修复 WindowsApps `bash.exe` 探测可能无限挂起的问题并记录 PF-20260727-1322。
- **source_chat**: [Roblox Bundle 安全接入](540edfb7-b92d-4680-b9d4-3b506604993d)
### [CL-20260727-1213-nostars] 2026-07-27 12:13 — 建立多项目类型 Cursor 初始化架构
- **merge_source**: FOR_Roblox_Main
- **author**: nostars (Nostars Developer)
- **type**: feat
- **tags**: cursor-init, multi-project, manifest, hooks, godot, roblox
- **affected_files**:
- .cursor-init-source
- .cursor/bootstrap/catalog.json
- .cursor/bootstrap/common.manifest.json
- .cursor/bootstrap/skills.json
- .cursor/bootstrap/tools/cursor_init.py
- .cursor/bootstrap/tests/test_cursor_init.py
- .cursor/bootstrap/types/general/manifest.json
- .cursor/bootstrap/types/godot/manifest.json
- .cursor/bootstrap/types/roblox/manifest.json
- .cursor/bootstrap/types/godot/payload/rules/project/godot-architecture.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-assets.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-data-systems.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-gdscript-style.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-scene-conventions.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-testing.mdc
- .cursor/hooks/changelog-scan.json
- .cursor/hooks/check-changelog.ps1
- .cursor/hooks/check-changelog.sh
- .cursor/hooks/tests/test_check_changelog.py
- .cursor/local-env.example.json
- .cursor/rules/common/baserule.mdc
- .cursor/rules/common/cursor-init-recall.mdc
- .cursor/rules/common/epee-orchestrator.mdc
- .cursor/skills/cursor-init/SKILL.md
- .cursor/skills/cursor-init/reference.md
- .cursor/skills/epee-orchestrator/SKILL.md
- .cursor/skills/epee-orchestrator/registry.md
- .cursor/README.md
- .gitignore
- .gitattributes
- **what**: 将固定 Godot 白名单重构为 common + 单选项目类型的 manifest 驱动初始化系统,加入摘要确认、所有权 hash、foreign/conflict 阻断、状态重置与 sentinel 验证。
- **why**: 原结构在未初始化时就加载 Godot Rule并把 Rule/Skill 数量、环境字段、Hook 扫描和 Git 配置写死,无法安全扩展通用或 Roblox 项目。
- **decisions**: common 只保留活动副本;项目类型 payload 自包含保存在 `.cursor/bootstrap/types/`;使用 JSON 与 Python 3.10 标准库;`general` 为 common-onlyRoblox 通过 readiness 门槛后才能从 draft 切为 ready。
- **tests**: 初始化引擎 11 项 unittest 与 PowerShell/Bash Hook 6 项回归测试通过catalog `--check`、ready 类型门控、general/Godot 实仓 dry-run、changelog `--check``git diff --check` 和 IDE lint 均通过。
- **notes**: 保留并泛化此前 Hook 的 session + Git + mtime 防误报守卫;新增根 `.cursor-init-source`,避免本初始化源码仓被自身自动初始化。
- **source_chat**: [多类型初始化架构重构](540edfb7-b92d-4680-b9d4-3b506604993d)

View File

@@ -2,3 +2,7 @@
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。
- [CL-20260727-1322-nostars] @nostars Roblox 初始化现仅按类型物化安全整改后的项目 Rule 与十个专属 Skill。 [merge:FOR_Roblox_Main]
- [CL-20260727-1213-nostars] @nostars 以 common 常驻、单选类型惰性物化的方式支持通用与 Godot 初始化。 [merge:FOR_Roblox_Main]

View File

@@ -3,3 +3,24 @@
最近 ~20 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。
> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。
### [CL-20260727-1322-nostars] 2026-07-27 — 安全接入 Roblox 项目类型 Bundle
- **merge_source**: FOR_Roblox_Main
- **author**: nostars
- **tags**: cursor-init, roblox, skills, local-env, open-cloud, security
- **affected_files**: .cursor/bootstrap/catalog.json、.cursor/bootstrap/skills.json、.cursor/bootstrap/tools/cursor_init.py、.cursor/bootstrap/tests/test_cursor_init.py、.cursor/bootstrap/types/roblox/manifest.json、.cursor/bootstrap/types/roblox/fragments/roblox.gitignore、.cursor/bootstrap/types/roblox/fragments/roblox.gitattributes、.cursor/bootstrap/types/roblox/payload/rules/project/roblox-luau-guardrails.mdc、.cursor/bootstrap/types/roblox/payload/skills/roblox-project-initializer/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/roblox-vibe-project-framework/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/roblox-ui-producer/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/building-3d-objects/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/building-maps/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/roblox-camera-cinematics/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/roblox-vfx-designer/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/roblox-animation-producer/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/roblox-commonkit-usage/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/roblox-asset-uploader/SKILL.md、.cursor/bootstrap/types/roblox/payload/skills/roblox-asset-uploader/scripts/upload.py、.cursor/bootstrap/types/roblox/payload/skills/roblox-asset-uploader/scripts/test_upload.py、.cursor/skills/cursor-init/SKILL.md、.cursor/skills/cursor-init/reference.md、.cursor/hooks/tests/test_check_changelog.py、.cursor/pitfalls/pitfalls.md
- **summary**: 将十四个候选整合为一条 Roblox Rule、八个核心 Skill 和两个条件 Skill并把 Roblox 标记为 ready。
初始化器新增敏感本机字段契约API key 只在模板中留占位符,真实值不经问卷或命令行采集。
Open Cloud 上传器改为 Python 标准库、零联网 dry-run 和逐次确认,三类型切换与 Hook 扫描均有回归覆盖。
- **source_chat**: [Roblox Bundle 安全接入](540edfb7-b92d-4680-b9d4-3b506604993d)
### [CL-20260727-1213-nostars] 2026-07-27 — 建立多项目类型 Cursor 初始化架构
- **merge_source**: FOR_Roblox_Main
- **author**: nostars
- **tags**: cursor-init, multi-project, manifest, hooks, godot, roblox
- **affected_files**: .cursor-init-source、.cursor/bootstrap/catalog.json、.cursor/bootstrap/common.manifest.json、.cursor/bootstrap/skills.json、.cursor/bootstrap/tools/cursor_init.py、.cursor/bootstrap/tests/test_cursor_init.py、.cursor/bootstrap/types/general/manifest.json、.cursor/bootstrap/types/godot/manifest.json、.cursor/bootstrap/types/roblox/manifest.json、.cursor/bootstrap/types/godot/payload/rules/project/godot-architecture.mdc、.cursor/bootstrap/types/godot/payload/rules/project/godot-assets.mdc、.cursor/bootstrap/types/godot/payload/rules/project/godot-data-systems.mdc、.cursor/bootstrap/types/godot/payload/rules/project/godot-gdscript-style.mdc、.cursor/bootstrap/types/godot/payload/rules/project/godot-scene-conventions.mdc、.cursor/bootstrap/types/godot/payload/rules/project/godot-testing.mdc、.cursor/hooks/changelog-scan.json、.cursor/hooks/check-changelog.ps1、.cursor/hooks/check-changelog.sh、.cursor/hooks/tests/test_check_changelog.py、.cursor/local-env.example.json、.cursor/rules/common/baserule.mdc、.cursor/rules/common/cursor-init-recall.mdc、.cursor/rules/common/epee-orchestrator.mdc、.cursor/skills/cursor-init/SKILL.md、.cursor/skills/cursor-init/reference.md、.cursor/skills/epee-orchestrator/SKILL.md、.cursor/skills/epee-orchestrator/registry.md、.cursor/README.md、.gitignore、.gitattributes
- **summary**: 新增 JSON catalog/manifests、标准库 Python dry-run/apply/check 引擎与自包含 bootstrap。
Godot Rules 移入未激活 payload通用项目初始化前不再加载 Godot 约束。
Registry、Hook 扫描、本机字段和 Git managed blocks 均按所选类型生成。
Roblox 保持 draft只有 payload 和测试完整后才会出现在动态问卷中。
- **source_chat**: [多类型初始化架构重构](540edfb7-b92d-4680-b9d4-3b506604993d)

View File

@@ -0,0 +1,66 @@
---
id: CL-20260727-1213-nostars
date: 2026-07-27 12:13
author: nostars
author_name: Nostars Developer
type: feat
merge_source: FOR_Roblox_Main
status: active
superseded_by:
title: 建立多项目类型 Cursor 初始化架构
source_chat: [多类型初始化架构重构](540edfb7-b92d-4680-b9d4-3b506604993d)
tags:
- cursor-init
- multi-project
- manifest
- hooks
- godot
- roblox
affected_files:
- .cursor-init-source
- .cursor/bootstrap/catalog.json
- .cursor/bootstrap/common.manifest.json
- .cursor/bootstrap/skills.json
- .cursor/bootstrap/tools/cursor_init.py
- .cursor/bootstrap/tests/test_cursor_init.py
- .cursor/bootstrap/types/general/manifest.json
- .cursor/bootstrap/types/godot/manifest.json
- .cursor/bootstrap/types/roblox/manifest.json
- .cursor/bootstrap/types/godot/payload/rules/project/godot-architecture.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-assets.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-data-systems.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-gdscript-style.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-scene-conventions.mdc
- .cursor/bootstrap/types/godot/payload/rules/project/godot-testing.mdc
- .cursor/hooks/changelog-scan.json
- .cursor/hooks/check-changelog.ps1
- .cursor/hooks/check-changelog.sh
- .cursor/hooks/tests/test_check_changelog.py
- .cursor/local-env.example.json
- .cursor/rules/common/baserule.mdc
- .cursor/rules/common/cursor-init-recall.mdc
- .cursor/rules/common/epee-orchestrator.mdc
- .cursor/skills/cursor-init/SKILL.md
- .cursor/skills/cursor-init/reference.md
- .cursor/skills/epee-orchestrator/SKILL.md
- .cursor/skills/epee-orchestrator/registry.md
- .cursor/README.md
- .gitignore
- .gitattributes
---
<!-- L3 -->
以 common 常驻、单选类型惰性物化的方式支持通用与 Godot 初始化。
<!-- L2 -->
新增 JSON catalog/manifests、标准库 Python dry-run/apply/check 引擎与自包含 bootstrap。
Godot Rules 移入未激活 payload通用项目初始化前不再加载 Godot 约束。
Registry、Hook 扫描、本机字段和 Git managed blocks 均按所选类型生成。
Roblox 保持 draft只有 payload 和测试完整后才会出现在动态问卷中。
<!-- L1 -->
- **what**: 将固定 Godot 白名单重构为 common + 单选项目类型的 manifest 驱动初始化系统,加入摘要确认、所有权 hash、foreign/conflict 阻断、状态重置与 sentinel 验证。
- **why**: 原结构在未初始化时就加载 Godot Rule并把 Rule/Skill 数量、环境字段、Hook 扫描和 Git 配置写死,无法安全扩展通用或 Roblox 项目。
- **decisions**: common 只保留活动副本;项目类型 payload 自包含保存在 `.cursor/bootstrap/types/`;使用 JSON 与 Python 3.10 标准库;`general` 为 common-onlyRoblox 通过 readiness 门槛后才能从 draft 切为 ready。
- **tests**: 初始化引擎 11 项 unittest 与 PowerShell/Bash Hook 6 项回归测试通过catalog `--check`、ready 类型门控、general/Godot 实仓 dry-run、changelog `--check``git diff --check` 和 IDE lint 均通过。
- **notes**: 保留并泛化此前 Hook 的 session + Git + mtime 防误报守卫;新增根 `.cursor-init-source`,避免本初始化源码仓被自身自动初始化。

View File

@@ -0,0 +1,59 @@
---
id: CL-20260727-1322-nostars
date: 2026-07-27 13:22
author: nostars
author_name: Nostars Developer
type: feat
merge_source: FOR_Roblox_Main
status: active
superseded_by:
title: 安全接入 Roblox 项目类型 Bundle
source_chat: [Roblox Bundle 安全接入](540edfb7-b92d-4680-b9d4-3b506604993d)
tags:
- cursor-init
- roblox
- skills
- local-env
- open-cloud
- security
affected_files:
- .cursor/bootstrap/catalog.json
- .cursor/bootstrap/skills.json
- .cursor/bootstrap/tools/cursor_init.py
- .cursor/bootstrap/tests/test_cursor_init.py
- .cursor/bootstrap/types/roblox/manifest.json
- .cursor/bootstrap/types/roblox/fragments/roblox.gitignore
- .cursor/bootstrap/types/roblox/fragments/roblox.gitattributes
- .cursor/bootstrap/types/roblox/payload/rules/project/roblox-luau-guardrails.mdc
- .cursor/bootstrap/types/roblox/payload/skills/roblox-project-initializer/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-vibe-project-framework/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-ui-producer/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/building-3d-objects/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/building-maps/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-camera-cinematics/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-vfx-designer/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-animation-producer/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-commonkit-usage/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-asset-uploader/SKILL.md
- .cursor/bootstrap/types/roblox/payload/skills/roblox-asset-uploader/scripts/upload.py
- .cursor/bootstrap/types/roblox/payload/skills/roblox-asset-uploader/scripts/test_upload.py
- .cursor/skills/cursor-init/SKILL.md
- .cursor/skills/cursor-init/reference.md
- .cursor/hooks/tests/test_check_changelog.py
- .cursor/pitfalls/pitfalls.md
---
<!-- L3 -->
Roblox 初始化现仅按类型物化安全整改后的项目 Rule 与十个专属 Skill。
<!-- L2 -->
将十四个候选整合为一条 Roblox Rule、八个核心 Skill 和两个条件 Skill并把 Roblox 标记为 ready。
初始化器新增敏感本机字段契约API key 只在模板中留占位符,真实值不经问卷或命令行采集。
Open Cloud 上传器改为 Python 标准库、零联网 dry-run 和逐次确认,三类型切换与 Hook 扫描均有回归覆盖。
<!-- L1 -->
- **what**: 建立 Roblox 自包含 payload、manifest、Registry 元数据、扫描/Git 片段与类型专属本机字段;去除 Codex 配置、个人路径、项目 API 残留,并合并 Guardrails、Design Consultant 与 Retrospective 的有效能力。
- **why**: Roblox 候选来自特定 Codex/SwordnWizard 环境,原样接入会提前加载错误 Skill、泄漏环境假设并让 API key 和外部上传缺少安全边界。
- **decisions**: Roblox 选择时才物化十个 SkillCommonKit 与 Asset Uploader 保持条件触发Guardrails 转 always-on 项目 Rule敏感字段禁止 `--set` 且不参与 drift不保存密码、Cookie 或会话令牌;上传器不新增第三方依赖。
- **tests**: 初始化 18 项、Hook 7 项、上传器 6 项测试通过;真实 payload 在临时目标完成 Roblox → General → Godot → Roblox 切换并逐段 `--check`;三个类型实仓 dry-run 无 blocker敏感 key 命令行输入被拒绝且轮换不改变计划摘要IDE lint 无错误。
- **notes**: 验证成功后删除 `roblox_skills_waitingforprocessing``.cursor/bootstrap/types/roblox/payload` 成为唯一 canonical。另修复 WindowsApps `bash.exe` 探测可能无限挂起的问题并记录 PF-20260727-1322。

View File

@@ -9,6 +9,8 @@ hooks/
├── session-init.sh # sessionStartBash
├── check-changelog.ps1 # stop / changelog guardPowerShell
├── check-changelog.sh # stop / changelog guardBash
├── changelog-scan.json # cursor-init 按项目类型生成的活动扫描策略
├── tests/ # PowerShell / Bash 共用回归测试
└── README.md
```
@@ -51,11 +53,12 @@ macOS/Linux 不需要 Windows dispatcher。将 `.cursor/hooks.json` 改为 Bash
}
```
同时把本机 `.cursor/local-env.json``shell` 设为 `bash`(或实际使用的 `zsh`。Bash 脚本不依赖 `jq`;优先用 `python3`Windows Git Bash 可回退 `python`安全解析/生成 JSON解释器缺失或解析失败时 fail-open 返回 `{}`
同时把本机 `.cursor/local-env.json``shell` 设为 `bash`(或实际使用的 `zsh`。Bash 脚本不依赖 `jq`;优先用 `python3`Windows Git Bash 可回退 `python`读取扫描配置并生成 JSON解释器缺失或解析失败时 fail-open 返回 `{}`
## 行为
- `session-init` 清除上一会话的 `.cursor/changelog/.changelog-ack`,并刷新 gitignored 的 `.cursor/changelog/.session-start`。非 `agent` 模式通过 hook `env` 设置 `CURSOR_SKIP_CHANGELOG=1`
- Cursor 的 `stop` 输入不包含可靠的工具编辑历史,因此 `check-changelog` 不解析不存在的“编辑证据”。它只在未 ack、未被 session 环境跳过,且 Godot 源码/配置同时晚于本会话起点与当前 changelog 时返回 `followup_message`,避免旧会话残留 mtime 误报
- 扫描从仓库根开始,覆盖 `project.godot``.gd``.tscn``.tres``.res``.gdshader``.cfg``.json``.cs``.csproj` 及常用工具脚本;跳过 `.git``.cursor``.godot`、依赖目录、Python 缓存/虚拟环境和常见构建输出目录
- Cursor 的 `stop` 输入不包含可靠的工具编辑历史,因此 `check-changelog` 使用 Git 工作区状态与文件时间共同判断。只有 `changelog-scan.json` 命中的项目源码/配置同时存在未提交内容差异,且晚于本会话起点与当前 changelog 时,才返回 `followup_message`
- `.session-start` 缺失、Git 不可用、状态读取失败或工作区无内容差异时均 fail-open 返回 `{}`。不得只因文件 mtime 较新就要求写开发日志
- 扫描文件名、扩展名与排除目录由 common + 所选项目类型 manifest 合并生成Hook 代码不得重新硬编码 Godot 或 Roblox 列表。
- 所有正常与失败路径都在 stdout 输出一个合法 JSON 对象;检查失败默认不阻断。

View File

@@ -0,0 +1,11 @@
{
"schema_version": 1,
"project_type": "bootstrap",
"display_name": "初始化基线",
"included_names": [],
"included_extensions": [],
"excluded_directories": [
".git",
".cursor"
]
}

View File

@@ -1,4 +1,4 @@
# Changelog sync guard: compare Godot source/config mtimes against this session and changelog.
# Changelog sync guard: require Git changes newer than this session and changelog.
$utf8 = New-Object System.Text.UTF8Encoding($false)
[Console]::InputEncoding = $utf8
[Console]::OutputEncoding = $utf8
@@ -20,8 +20,15 @@ $repoRoot = Split-Path -Parent $cursorDir
$changelog = Join-Path $cursorDir 'changelog\changelog-headlines.md'
$ackFile = Join-Path $cursorDir 'changelog\.changelog-ack'
$sessionMarker = Join-Path $cursorDir 'changelog\.session-start'
$scanConfigPath = Join-Path $PSScriptRoot 'changelog-scan.json'
if (-not (Test-Path -LiteralPath $changelog -PathType Leaf)) {
if (-not (Test-Path -LiteralPath $changelog -PathType Leaf) -or
-not (Test-Path -LiteralPath $scanConfigPath -PathType Leaf)) {
Write-EmptyHookResult
exit 0
}
if (-not (Test-Path -LiteralPath $sessionMarker -PathType Leaf)) {
Write-EmptyHookResult
exit 0
}
@@ -33,51 +40,50 @@ if ((Test-Path -LiteralPath $ackFile -PathType Leaf) -and
exit 0
}
$comparisonMtime = $changelogMtime
if ((Test-Path -LiteralPath $sessionMarker -PathType Leaf)) {
$sessionMtime = (Get-Item -LiteralPath $sessionMarker).LastWriteTimeUtc
if ($sessionMtime -gt $comparisonMtime) {
$comparisonMtime = $sessionMtime
}
$gitStatus = @(& git -C $repoRoot status --porcelain=v1 --untracked-files=all -- 2>$null)
if ($LASTEXITCODE -ne 0 -or $gitStatus.Count -eq 0) {
Write-EmptyHookResult
exit 0
}
$includedNames = @('project.godot')
$includedExtensions = @(
'.gd',
'.tscn',
'.tres',
'.res',
'.gdshader',
'.cfg',
'.json',
'.cs',
'.csproj',
'.py',
'.ps1',
'.sh'
)
$excludedDirectoryNames = @(
'.git',
'.cursor',
'.godot',
'node_modules',
'__pycache__',
'venv',
'.venv',
'dist',
'build',
'out',
'bin',
'obj',
'export',
'exports',
'coverage',
'tmp',
'temp'
)
try {
$scanConfig = Get-Content -LiteralPath $scanConfigPath -Raw -Encoding UTF8 |
ConvertFrom-Json
if ($scanConfig.schema_version -ne 1) {
throw "Unsupported changelog scan schema."
}
$includedNames = @(
$scanConfig.included_names |
Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }
)
$includedExtensions = @(
$scanConfig.included_extensions |
Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }
)
$excludedDirectoryNames = @(
$scanConfig.excluded_directories |
Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }
)
} catch {
Write-EmptyHookResult
exit 0
}
if ($includedNames.Count -eq 0 -and $includedExtensions.Count -eq 0) {
Write-EmptyHookResult
exit 0
}
$comparisonMtime = $changelogMtime
$sessionMtime = (Get-Item -LiteralPath $sessionMarker).LastWriteTimeUtc
if ($sessionMtime -gt $comparisonMtime) {
$comparisonMtime = $sessionMtime
}
$newerFile = $null
$newerRelativePath = $null
$rootItem = Get-Item -LiteralPath $repoRoot
$rootPrefix = $repoRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$directories = New-Object 'System.Collections.Generic.Stack[System.IO.DirectoryInfo]'
$directories.Push($rootItem)
@@ -101,20 +107,29 @@ while ($directories.Count -gt 0 -and -not $newerFile) {
$isIncluded = ($includedNames -contains $entry.Name) -or
($includedExtensions -contains $entry.Extension)
if ($isIncluded -and $entry.LastWriteTimeUtc -gt $comparisonMtime) {
$newerFile = $entry
break
$relativePath = if ($entry.FullName.StartsWith(
$rootPrefix,
[System.StringComparison]::OrdinalIgnoreCase
)) {
$entry.FullName.Substring($rootPrefix.Length)
} else {
$entry.Name
}
$gitPath = $relativePath -replace '\\', '/'
$pathStatus = @(
& git -C $repoRoot status --porcelain=v1 --untracked-files=all -- $gitPath 2>$null
)
if ($LASTEXITCODE -eq 0 -and $pathStatus.Count -gt 0) {
$newerFile = $entry
$newerRelativePath = $relativePath
break
}
}
}
}
if ($newerFile) {
$rootPrefix = $repoRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$relativePath = if ($newerFile.FullName.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$newerFile.FullName.Substring($rootPrefix.Length)
} else {
$newerFile.Name
}
$message = "[Hook] Godot source/config is newer than the changelog (e.g. $relativePath). Run dev-changelog Skill operation A now: add a fragment under .cursor/changelog/entries/ and rebuild the changelog views."
$message = "[Hook] Project source/config has an uncommitted change newer than this session and the changelog (e.g. $newerRelativePath). Run dev-changelog Skill operation A now: add a fragment under .cursor/changelog/entries/ and rebuild the changelog views."
$result = [ordered]@{ followup_message = $message }
[Console]::Out.WriteLine(($result | ConvertTo-Json -Compress -Depth 3))
} else {

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Changelog sync guard比较 Godot 源码/配置与本会话起点、changelog 的 mtime
# Changelog sync guard按活动扫描配置提示本会话内尚未提交的项目改动
emit_empty() {
printf '{}\n'
@@ -18,8 +18,14 @@ repo_root=$(dirname -- "$cursor_dir")
changelog="$cursor_dir/changelog/changelog-headlines.md"
ack_file="$cursor_dir/changelog/.changelog-ack"
session_marker="$cursor_dir/changelog/.session-start"
scan_config="$script_dir/changelog-scan.json"
if [ ! -f "$changelog" ]; then
if [ ! -f "$changelog" ] || [ ! -f "$scan_config" ]; then
emit_empty
exit 0
fi
if [ ! -f "$session_marker" ]; then
emit_empty
exit 0
fi
@@ -38,66 +44,6 @@ if [ -f "$ack_file" ]; then
fi
fi
comparison_file=$changelog
if [ -f "$session_marker" ]; then
changelog_mtime=$(get_mtime "$changelog")
session_mtime=$(get_mtime "$session_marker")
if [ -n "$changelog_mtime" ] && [ -n "$session_mtime" ] &&
[ "$session_mtime" -gt "$changelog_mtime" ] 2>/dev/null; then
comparison_file=$session_marker
fi
fi
newer_file=''
while IFS= read -r candidate; do
newer_file=$candidate
break
done < <(
find "$repo_root" \
\( -type d \( \
-name '.git' -o \
-name '.cursor' -o \
-name '.godot' -o \
-name 'node_modules' -o \
-name '__pycache__' -o \
-name 'venv' -o \
-name '.venv' -o \
-name 'dist' -o \
-name 'build' -o \
-name 'out' -o \
-name 'bin' -o \
-name 'obj' -o \
-name 'export' -o \
-name 'exports' -o \
-name 'coverage' -o \
-name 'tmp' -o \
-name 'temp' \
\) -prune \) -o \
\( -type f \( \
-iname 'project.godot' -o \
-iname '*.gd' -o \
-iname '*.tscn' -o \
-iname '*.tres' -o \
-iname '*.res' -o \
-iname '*.gdshader' -o \
-iname '*.cfg' -o \
-iname '*.json' -o \
-iname '*.cs' -o \
-iname '*.csproj' -o \
-iname '*.py' -o \
-iname '*.ps1' -o \
-iname '*.sh' \
\) -newer "$comparison_file" -print \) 2>/dev/null
)
if [ -z "$newer_file" ]; then
emit_empty
exit 0
fi
relative_file=${newer_file#"$repo_root"/}
message="[Hook] Godot source/config is newer than the changelog (e.g. $relative_file). Run dev-changelog Skill operation A now: add a fragment under .cursor/changelog/entries/ and rebuild the changelog views."
python_cmd=''
for candidate in python3 python; do
if command -v "$candidate" >/dev/null 2>&1 &&
@@ -107,24 +53,111 @@ for candidate in python3 python; do
fi
done
if [ -n "$python_cmd" ]; then
result=$(
printf '%s' "$message" | "$python_cmd" -c '
if [ -z "$python_cmd" ]; then
emit_empty
exit 0
fi
result=$(
"$python_cmd" - "$repo_root" "$changelog" "$session_marker" "$scan_config" <<'PY'
import json
import os
from pathlib import Path
import subprocess
import sys
print(json.dumps(
{"followup_message": sys.stdin.read()},
ensure_ascii=False,
separators=(",", ":"),
))
' 2>/dev/null
) || result='{}'
if [ -n "$result" ]; then
printf '%s\n' "$result"
else
emit_empty
fi
repo_root = Path(sys.argv[1]).resolve()
changelog = Path(sys.argv[2])
session_marker = Path(sys.argv[3])
scan_config = Path(sys.argv[4])
def emit(value):
print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
try:
config = json.loads(scan_config.read_text(encoding="utf-8"))
if config.get("schema_version") != 1:
raise ValueError("unsupported schema")
names = {
value.casefold()
for value in config.get("included_names", [])
if isinstance(value, str) and value
}
extensions = {
value.casefold()
for value in config.get("included_extensions", [])
if isinstance(value, str) and value
}
excluded = {
value.casefold()
for value in config.get("excluded_directories", [])
if isinstance(value, str) and value
}
if not names and not extensions:
emit({})
raise SystemExit(0)
status = subprocess.run(
["git", "-C", str(repo_root), "status", "--porcelain=v1",
"--untracked-files=all", "--"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
)
if status.returncode != 0 or not status.stdout.strip():
emit({})
raise SystemExit(0)
comparison_mtime = max(
changelog.stat().st_mtime,
session_marker.stat().st_mtime,
)
for current_root, directory_names, file_names in os.walk(repo_root):
directory_names[:] = [
name for name in directory_names if name.casefold() not in excluded
]
current = Path(current_root)
for file_name in file_names:
candidate = current / file_name
if (
file_name.casefold() not in names
and candidate.suffix.casefold() not in extensions
):
continue
try:
if candidate.stat().st_mtime <= comparison_mtime:
continue
except OSError:
continue
relative = candidate.relative_to(repo_root).as_posix()
path_status = subprocess.run(
["git", "-C", str(repo_root), "status", "--porcelain=v1",
"--untracked-files=all", "--", relative],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
)
if path_status.returncode == 0 and path_status.stdout.strip():
message = (
"[Hook] Project source/config has an uncommitted change newer "
f"than this session and the changelog (e.g. {relative}). "
"Run dev-changelog Skill operation A now: add a fragment under "
".cursor/changelog/entries/ and rebuild the changelog views."
)
emit({"followup_message": message})
raise SystemExit(0)
emit({})
except SystemExit:
raise
except Exception:
emit({})
PY
) || result='{}'
if [ -n "$result" ]; then
printf '%s\n' "$result"
else
emit_empty
fi

View File

@@ -0,0 +1,297 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
import unittest
HOOKS_DIR = Path(__file__).resolve().parents[1]
class CheckChangelogHookTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.implementations: list[tuple[str, list[str], str]] = []
powershell = shutil.which("powershell") or shutil.which("pwsh")
if powershell:
cls.implementations.append(
(
"powershell",
[
powershell,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
],
"check-changelog.ps1",
)
)
bash_candidates: list[Path] = []
bash_from_path = shutil.which("bash")
if bash_from_path:
bash_candidates.append(Path(bash_from_path))
git_from_path = shutil.which("git")
if git_from_path:
bash_candidates.append(Path(git_from_path).resolve().parent.parent / "bin" / "bash.exe")
bash: Path | None = None
for candidate in dict.fromkeys(bash_candidates):
if not candidate.is_file():
continue
try:
bash_probe = subprocess.run(
[str(candidate), "--version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=10,
)
except subprocess.TimeoutExpired:
continue
if bash_probe.returncode == 0:
bash = candidate
break
if bash:
cls.implementations.append(("bash", [str(bash)], "check-changelog.sh"))
if not cls.implementations:
raise unittest.SkipTest("PowerShell 和 Bash 均不可用")
def _create_fixture(self, script_name: str) -> tuple[tempfile.TemporaryDirectory[str], Path]:
temporary_directory = tempfile.TemporaryDirectory()
root = Path(temporary_directory.name)
hooks_dir = root / ".cursor" / "hooks"
changelog_dir = root / ".cursor" / "changelog"
hooks_dir.mkdir(parents=True)
changelog_dir.mkdir(parents=True)
shutil.copy2(HOOKS_DIR / script_name, hooks_dir / script_name)
(hooks_dir / "changelog-scan.json").write_text(
json.dumps(
{
"schema_version": 1,
"project_type": "test",
"display_name": "Test",
"included_names": [],
"included_extensions": [".json"],
"excluded_directories": [".git", ".cursor"],
},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
newline="\n",
)
(changelog_dir / "changelog-headlines.md").write_text(
"# Dev Changelog — Headlines\n",
encoding="utf-8",
newline="\n",
)
(root / ".gitignore").write_text(
".cursor/changelog/.session-start\n",
encoding="utf-8",
newline="\n",
)
(root / "config.json").write_text('{"value": 1}\n', encoding="utf-8", newline="\n")
(root / "player.gd").write_text("extends Node\n", encoding="utf-8", newline="\n")
(root / "player.luau").write_text(
"--!strict\nreturn {}\n",
encoding="utf-8",
newline="\n",
)
self._git(root, "init", "-q")
self._git(root, "config", "user.email", "hook-test@example.invalid")
self._git(root, "config", "user.name", "Hook Test")
self._git(root, "add", ".")
self._git(root, "commit", "-q", "-m", "fixture")
return temporary_directory, root
def _git(self, root: Path, *arguments: str) -> None:
subprocess.run(
["git", "-C", str(root), *arguments],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
def _run_hook(self, command_prefix: list[str], root: Path, script_name: str) -> dict[str, str]:
completed = subprocess.run(
[*command_prefix, str(root / ".cursor" / "hooks" / script_name)],
input="{}",
check=True,
capture_output=True,
text=True,
encoding="utf-8",
timeout=30,
)
return json.loads(completed.stdout)
def _write_scan_config(self, root: Path, extensions: list[str]) -> None:
path = root / ".cursor" / "hooks" / "changelog-scan.json"
path.write_text(
json.dumps(
{
"schema_version": 1,
"project_type": "test",
"display_name": "Test",
"included_names": [],
"included_extensions": extensions,
"excluded_directories": [".git", ".cursor"],
},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
newline="\n",
)
def _set_times(
self,
root: Path,
*,
source_after_session: bool,
source_name: str = "config.json",
) -> None:
baseline = int(time.time()) - 20
changelog = root / ".cursor" / "changelog" / "changelog-headlines.md"
session_marker = root / ".cursor" / "changelog" / ".session-start"
source = root / source_name
session_marker.touch()
os.utime(changelog, (baseline, baseline))
os.utime(session_marker, (baseline + 5, baseline + 5))
source_time = baseline + 10 if source_after_session else baseline + 2
os.utime(source, (source_time, source_time))
def test_missing_session_marker_returns_empty(self) -> None:
for name, command_prefix, script_name in self.implementations:
with self.subTest(implementation=name):
temporary_directory, root = self._create_fixture(script_name)
self.addCleanup(temporary_directory.cleanup)
source = root / "config.json"
future = int(time.time()) + 5
os.utime(source, (future, future))
self.assertEqual(self._run_hook(command_prefix, root, script_name), {})
def test_clean_worktree_with_newer_mtime_returns_empty(self) -> None:
for name, command_prefix, script_name in self.implementations:
with self.subTest(implementation=name):
temporary_directory, root = self._create_fixture(script_name)
self.addCleanup(temporary_directory.cleanup)
self._set_times(root, source_after_session=True)
self.assertEqual(self._run_hook(command_prefix, root, script_name), {})
def test_uncommitted_change_newer_than_session_returns_followup(self) -> None:
for name, command_prefix, script_name in self.implementations:
with self.subTest(implementation=name):
temporary_directory, root = self._create_fixture(script_name)
self.addCleanup(temporary_directory.cleanup)
(root / "config.json").write_text(
'{"value": 2}\n',
encoding="utf-8",
newline="\n",
)
self._set_times(root, source_after_session=True)
result = self._run_hook(command_prefix, root, script_name)
self.assertIn("followup_message", result)
self.assertIn("config.json", result["followup_message"])
def test_change_older_than_session_returns_empty(self) -> None:
for name, command_prefix, script_name in self.implementations:
with self.subTest(implementation=name):
temporary_directory, root = self._create_fixture(script_name)
self.addCleanup(temporary_directory.cleanup)
(root / "config.json").write_text(
'{"value": 2}\n',
encoding="utf-8",
newline="\n",
)
self._set_times(root, source_after_session=False)
self.assertEqual(self._run_hook(command_prefix, root, script_name), {})
def test_empty_profile_never_returns_followup(self) -> None:
for name, command_prefix, script_name in self.implementations:
with self.subTest(implementation=name):
temporary_directory, root = self._create_fixture(script_name)
self.addCleanup(temporary_directory.cleanup)
self._write_scan_config(root, [])
(root / "config.json").write_text(
'{"value": 2}\n',
encoding="utf-8",
newline="\n",
)
self._set_times(root, source_after_session=True)
self.assertEqual(self._run_hook(command_prefix, root, script_name), {})
def test_godot_profile_matches_gd_but_not_unlisted_json(self) -> None:
for name, command_prefix, script_name in self.implementations:
with self.subTest(implementation=name):
temporary_directory, root = self._create_fixture(script_name)
self.addCleanup(temporary_directory.cleanup)
self._write_scan_config(root, [".gd"])
(root / "config.json").write_text(
'{"value": 2}\n',
encoding="utf-8",
newline="\n",
)
(root / "player.gd").write_text(
"extends Node2D\n",
encoding="utf-8",
newline="\n",
)
self._set_times(
root,
source_after_session=True,
source_name="player.gd",
)
result = self._run_hook(command_prefix, root, script_name)
self.assertIn("followup_message", result)
self.assertIn("player.gd", result["followup_message"])
def test_roblox_profile_matches_luau(self) -> None:
for name, command_prefix, script_name in self.implementations:
with self.subTest(implementation=name):
temporary_directory, root = self._create_fixture(script_name)
self.addCleanup(temporary_directory.cleanup)
self._write_scan_config(root, [".luau"])
(root / "player.luau").write_text(
"--!strict\nreturn { value = 2 }\n",
encoding="utf-8",
newline="\n",
)
self._set_times(
root,
source_after_session=True,
source_name="player.luau",
)
result = self._run_hook(command_prefix, root, script_name)
self.assertIn("followup_message", result)
self.assertIn("player.luau", result["followup_message"])
if __name__ == "__main__":
unittest.main()

View File

@@ -3,7 +3,5 @@
"shell": "powershell",
"_shell_comment": "Windows 可用 powershell 或 pwsh使用 Git Bash 时可填 bash。macOS/Linux 通常填 bash 或 zsh。",
"changelog-author": "<your-changelog-handle>",
"_changelog_author_comment": "用于 changelog 条目的作者标识,例如团队账号或个人 handle。",
"godot4_path": "<path-to-godot-4-executable>",
"_godot4_path_comment": "填写本机 Godot 4 可执行文件路径或可从 PATH 解析的命令名;不要把个人绝对路径写入模板。"
"_changelog_author_comment": "用于 changelog 条目的作者标识,例如团队账号或个人 handle。"
}

View File

@@ -2,6 +2,26 @@
开发过程中踩过的坑按时间倒序排列。Agent 进入调试或遇到运行时错误时可检索本文件。
### [PF-20260727-1322] WindowsApps bash 别名会让可用性探测挂起
- **症状**: Hook 跨平台测试在 `bash --version` 阶段长期无输出,整套测试超过超时仍不结束。
- **根因**: `shutil.which("bash")` 优先命中了 WindowsApps 的 WSL 应用执行别名;该占位 `bash.exe` 存在但不会像 Git Bash 一样快速返回版本,原探测也没有 subprocess timeout。
- **解法**: 给候选 bash 的版本探测增加短超时,捕获 `subprocess.TimeoutExpired` 后继续尝试 Git 安装目录中的 `bin/bash.exe`Hook 子进程本身也设置超时,避免再次无限等待。
- **防御**: Windows 上不能仅凭 `which` 和文件存在判断 CLI 可用;外部工具探测必须执行带超时的无副作用命令,并把单个候选超时视为“不可用”而不是全局失败。
- **关联**: `.cursor/hooks/tests/test_check_changelog.py`、WindowsApps、WSL alias、Git Bash、subprocess timeout
---
### [PF-20260727-1129] Changelog Hook 仅凭 mtime 产生无改动误报
- **症状**: Git 工作区没有任何内容差异,`stop` Hook 却因 `.vscode/settings.json` 时间戳较新而要求创建 changelog fragment。
- **根因**: `.session-start` 缺失时 Hook 回退到旧 changelog 时间,并且只比较宽泛扫描范围内文件的 mtime没有验证候选文件是否存在真实 Git 内容差异。
- **解法**: `.session-start` 缺失或 Git 状态不可用时 fail-open工作区干净时立即退出候选文件还必须被 `git status --porcelain` 判定为未提交改动。PowerShell 与 Bash 实现同步修复并添加回归测试。
- **防御**: 文件时间只能限定“本会话之后”不能作为内容改动证据Hook 提示写日志前必须同时满足会话时间与版本控制状态,并覆盖“仅 touch、无内容变化”的测试。
- **关联**: `.cursor/hooks/check-changelog.ps1``.cursor/hooks/check-changelog.sh``.cursor/hooks/tests/test_check_changelog.py`、Cursor stop Hook、Windows/Bash
---
<!--
### [PF-YYYYMMDD-HHMM] 简短标题

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