Compare commits

...

30 Commits

Author SHA1 Message Date
suzmii 8a47cdfc1b Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Native shell tests (pull_request) Failing after 15m58s
Project CI / Frontend tests (pull_request) Successful in 3m48s
Project CI / Backend tests (pull_request) Successful in 8m0s
Project CI / Repository checks (pull_request) Successful in 11m18s
2026-09-08 19:48:37 +08:00
suzmii 5550a7b28c 修复资源画布文案重命名后的CI失败
删除project-development.suite.ts中未使用的resetButton变量,修复ESLint unused-vars错误

将projectResourceLiveIntegration.test.tsx中过期的复位资源画布断言更新为复位资源视图
2026-09-08 16:38:49 +08:00
kdletters 1498247e9f AGC产物迁移npm与Phaser4
Project CI / Repository checks (push) Successful in 2m54s
Project CI / Native shell tests (push) Failing after 16m56s
Project CI / Frontend tests (push) Successful in 3m4s
Project CI / Backend tests (push) Successful in 8m10s
新增 npm + Vite + Phaser 4.2.1 游戏工程脚手架

允许 DirectProject 在项目边界内安装依赖并执行构建

让预览、静态检查、投影和试玩导出统一使用 dist 产物

补充 Phaser Skill、技术方案、项目记忆和定向测试
2026-09-08 15:11:36 +08:00
suzmii 7bd67f2b3a Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2 2026-09-08 14:58:58 +08:00
suzmii ea71540413 Merge branch 'feat/game-agent-canvas-resource-workbench-v2' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into feat/game-agent-canvas-resource-workbench-v2 2026-09-08 14:58:29 +08:00
suzmii 456cc42dd3 优化泥点流水的素材消耗原因展示 (#302)
Project CI / Repository checks (push) Successful in 1m59s
Project CI / Frontend tests (push) Successful in 3m7s
Project CI / Backend tests (push) Successful in 6m13s
Project CI / Native shell tests (push) Successful in 19m24s
Close #296

## 问题

钱包流水目前只把素材类扣费暴露为 `asset_operation_consume`,共享账单组件统一显示“资产操作消耗”,无法说明本次泥点实际用于哪类素材生成。

## 落地方案

- 在既有钱包流水 metadata 中记录服务端确定的 `assetKind`,不修改 SpacetimeDB schema。
- `api-server` 只将内部素材类型白名单映射为用户可见 `reason`,不暴露原始 metadata、资源 ID 或任务 ID。
- 共享钱包流水契约与组件优先展示具体原因;历史、未知或空 metadata 继续回退为“资产操作消耗”。
- 主站、图片画布与 AGC 继续复用同一共享账单组件。

## 当前进度

- [x] 补充权威后端数据契约
- [x] 写入素材操作类型 metadata
- [x] 扩展钱包流水公开 DTO 与安全映射
- [x] 更新共享账单展示与回归测试
- [x] 完成定向验证与边界检查

## 用户可见映射

- 图片、图标图集、美术规范、UI 设计、发布素材:生成美术素材
- 图片修改:编辑美术素材
- UI 素材提取:提取美术素材
- 角色动画:生成角色动画
- 视频:生成视频素材
- 音效:生成音效素材
- 背景音乐:生成背景音乐
- 历史、未知、空 metadata:资产操作消耗

## 验证

- `cargo fmt --all -- --check`
- `cargo test -p api-server profile_wallet_ledger_reason_only_exposes_known_asset_operations`
- `cargo test -p api-server worker_billing_context_freezes_charge_and_preserves_job_metadata`
- `cargo test -p shared-contracts profile_wallet_ledger_response_uses_camel_case_fields`
- `npx vitest run packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx`
- `npm run typecheck`
- `npx eslint packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx packages/shared/src/contracts/runtime.ts`
- `npm run check:encoding`
- `npm run check:spacetime-schema`
- `git diff --check`

以上均通过;Rust 仅输出仓库已有 dead-code warnings。

## 未验证边界

- 未执行真实登录、生成并读取新钱包流水的端到端联调。本机 `8082/3101` 已由另一套本地栈占用,为避免当前分支挂接同一数据库并带起 worker 处理现有队列,本轮未启动 `npm run dev:api-server`。
- 四项远端 CI 已全部通过,PR 已转为 Ready for review。

## 不变项

- 不修改历史流水数据。
- 不调整泥点价格、扣费顺序、幂等 ledger、失败退款或余额结算。
- 不修改 SpacetimeDB schema。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/302
Co-authored-by: Suzumiya <suzmii@qq.com>
Co-committed-by: Suzumiya <suzmii@qq.com>
2026-09-08 14:56:16 +08:00
suzmii 4e0ce1902b 建立AGC资源语义类型契约
共享契约新增 canonical 资源类型目录和历史类型读时映射

角色动画派生结果写入 character-animation 主类型

补充角色动画落盘与 legacy 类型映射回归测试

更新资源自由画板技术方案的类型边界说明
2026-09-08 13:41:55 +08:00
suzmii e2a50088c9 修复资源依赖本地身份解析
资源依赖图同时索引远端资源身份和 local-asset 本地资源身份

本地 canonical 引用与远端候选去重后再建立依赖连线

补充本地身份解析与候选不歧义回归测试

更新资源自由画板技术方案的依赖身份合同
2026-09-08 13:29:08 +08:00
suzmii 6de536ecbd Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Repository checks (pull_request) Failing after 1m23s
Project CI / Native shell tests (pull_request) Failing after 4m7s
Project CI / Backend tests (pull_request) Successful in 6m28s
Project CI / Frontend tests (pull_request) Failing after 2m7s
2026-09-08 12:24:11 +08:00
suzmii 1347b4a743 去除资源区滚轮切换栏目
Project CI / Frontend tests (pull_request) Failing after 1m49s
Project CI / Native shell tests (pull_request) Failing after 4m32s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 9s
删除普通滚轮切页状态、队列与节流控制逻辑

资源区普通滚轮只平移当前视图,缩放继续由 Ctrl 或 Cmd 配合滚轮触发

同步更新 PRD、技术方案与控制器测试
2026-09-08 12:12:29 +08:00
suzmii d5411eae62 去除资源区主子画布概念文案
资源总览、栏目入口、返回与复位文案移除主画布和子画布概念

资源栏目可访问名称改为设计文档、美术资源等名称

同步更新相关界面测试断言
2026-09-08 12:11:09 +08:00
k88936 4db6d28603 UI编辑器一批细节优化 (#278)
Project CI / Repository checks (push) Successful in 3m6s
Project CI / Frontend tests (push) Successful in 3m44s
Project CI / Backend tests (push) Successful in 8m16s
Project CI / Native shell tests (push) Successful in 20m48s
* 提示词:完善组件建议,增加容器背景与节点大小一致的实现
* 支持Delete删除节点 ctrl + -缩放
* 步骤结束弹窗提醒
![shotmd-1788599068.jpg](/attachments/99a1e4aa-d778-41bf-9dda-0df703723407)
![shotmd-1788605840.jpg](/attachments/31ab883d-32bc-467b-9308-616b83387a79)

* UI 树快捷删除
![shotmd-1788605655.jpg](/attachments/3d451201-3f68-4eb6-b558-864ecf0cb5a3)

* zoom滑动条样式

before:
![image.png](/attachments/e4a04304-e6eb-4da6-9352-99e093fee42e)
after:
![shotmd-1788606577.jpg](/attachments/6cdc377c-f2a3-4a7e-b78e-772a3fdb13b7)

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/278
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-08 11:56:36 +08:00
suzmii 0a5080dbd9 Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Repository checks (pull_request) Successful in 2m27s
Project CI / Native shell tests (pull_request) Successful in 18m40s
Project CI / Frontend tests (pull_request) Successful in 2m55s
Project CI / Backend tests (pull_request) Successful in 6m32s
2026-09-08 10:45:07 +08:00
suzmii c5bbb864f5 修复资源分页类别缓存回归
资源分页复用完整资源签名以识别同数量类别变化

补充同长度类别变化回归测试
2026-09-07 16:26:08 +08:00
suzmii ccb3a3ab6a 修复资源保存后的自动聚焦
Project CI / Frontend tests (pull_request) Successful in 3m21s
Project CI / Repository checks (pull_request) Successful in 6m18s
Project CI / Backend tests (pull_request) Successful in 6m46s
Project CI / Native shell tests (pull_request) Successful in 17m19s
将保存后资源卡的 DOM 查找范围改为资源画本管理器\n确保新派生资源和恢复资源在布局就绪后能够进入详情聚焦
2026-09-05 13:04:54 +08:00
suzmii 879e732b04 完善资源分页依赖
Project CI / Frontend tests (pull_request) Failing after 2m32s
Project CI / Repository checks (pull_request) Successful in 2m54s
Project CI / Native shell tests (pull_request) Failing after 6m19s
Project CI / Backend tests (pull_request) Successful in 6m39s
按资源类别签名刷新分页类别\n避免资源数量不变时使用过期类别,同时保持稳定引用避免无关编辑面板重置
2026-09-05 12:20:47 +08:00
suzmii cac3e19632 修复资源分页类别缓存
Project CI / Frontend tests (pull_request) Failing after 2m47s
Project CI / Repository checks (pull_request) Successful in 3m4s
Project CI / Native shell tests (pull_request) Failing after 5m49s
Project CI / Backend tests (pull_request) Successful in 7m12s
资源数组变化时重新计算资源分页类别\n避免资源总数不变但类别更新后继续使用旧分页状态
2026-09-05 11:59:23 +08:00
suzmii cd17ae8123 修复资源主画布无限平移与滚轮交互
分离固定交互视口与可平移缩放的内容层
支持主画布滚轮平移并保留缩放及主子画布视口记忆
补充越界拖动和滚轮回归测试并同步技术文档
2026-09-05 11:41:16 +08:00
suzmii 41c9ee7db4 Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Frontend tests (pull_request) Failing after 2m23s
Project CI / Repository checks (pull_request) Successful in 2m34s
Project CI / Native shell tests (pull_request) Failing after 4m58s
Project CI / Backend tests (pull_request) Successful in 6m51s
2026-09-05 11:23:18 +08:00
suzmii ba718e3d4b 重构资源画本独立转场与中断处理
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
使用独立FLIP快照层和Web Animations统一动画收尾
统一分类导航并保留滚轮节流和各画布视口
修复快照缩放与悬停导致的终点偏移
补充动画回归测试并更新资源画本技术文档
2026-09-05 11:21:17 +08:00
suzmii 65ffd4cc69 取消资源画本返回主画布的二次平移动画
新增 returning-main 阶段,区分返回主画布与进入子画布的 transform 过渡。

返回期间取消残留分页事件和进行中的子画布切换,避免返回流程被重新改写。

补充状态机与 appSurface 回归测试,并同步技术方案文档。
2026-09-05 09:25:52 +08:00
suzmii 5bcaca7233 重构资源画本画布与转场状态
统一主画布与子画布的可见渲染层和 viewport 状态管理。

将子画布改为固定视口加无限平移 world,并保证首次 fit 的可读比例。

补充转场、滚轮、viewport 和资源画本回归测试,更新技术方案文档。
2026-09-05 09:10:56 +08:00
suzmii 820fbfd988 修复子画布缩放并支持Ctrl滚轮与右下角缩放控件
美术资源初始适配不再放大过头

scene层支持Ctrl滚轮锚点缩放,普通滚轮保留翻页
2026-09-04 16:41:39 +08:00
suzmii 069248b296 子画布展开改用真实坐标与视口承载
资源卡拖拽使用模型坐标换算,消除阻力和回弹

子画布空白区域支持画布平移,保持进入退出动画连贯
2026-09-04 16:31:50 +08:00
suzmii 9c85a9501e 调整资源画本hover高亮仅用于未展开主画布
Project CI / Repository checks (pull_request) Failing after 2m58s
Project CI / Frontend tests (pull_request) Failing after 2m33s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
子画布展开态不再显示hover高亮线

未展开缩略卡hover时标题栏三边高亮,底边接缝保持默认色
2026-09-04 15:46:42 +08:00
suzmii 816844065b 拆分子画布标题与本体hover高亮
Project CI / Repository checks (pull_request) Failing after 2m52s
Project CI / Frontend tests (pull_request) Failing after 2m17s
Project CI / Native shell tests (pull_request) Failing after 6m15s
Project CI / Backend tests (pull_request) Successful in 6m37s
标题栏与本体分别作为独立对象绑定同一hover源

标题栏底边与本体顶边接缝保持默认色不高亮
2026-09-04 15:42:16 +08:00
suzmii 8323b13fd3 Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Frontend tests (pull_request) Failing after 2m29s
Project CI / Repository checks (pull_request) Failing after 2m49s
Project CI / Backend tests (pull_request) Successful in 6m39s
Project CI / Native shell tests (pull_request) Failing after 5m51s
2026-09-04 15:22:34 +08:00
suzmii f883d4bf2e 修复从项目入口首次进入主画布的错位
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Failing after 10s
移除主画布入场缩放动画避免测量和终态不一致

无效缩略卡测量结果不再直接展示共享场景
2026-09-04 15:13:19 +08:00
suzmii 019b1761f4 修复资源画本首次布局错位
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
首帧测量未完成时隐藏共享场景,避免标题和卡片使用默认坐标

在首帧和资源数量变化后重新校准缩略卡布局
2026-09-04 15:03:28 +08:00
suzmii f339b383f5 实现资源画本统一节点转场
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
重构主画布与子画布为共享资源卡和标题栏节点

补充缩略堆叠、进入返回动画、遮罩与定位适配

同步更新资源画本 UI 技术方案文档
2026-09-04 14:06:24 +08:00
77 changed files with 6739 additions and 821 deletions
+1
View File
@@ -53,6 +53,7 @@
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"phaser": "^4.2.1",
"react": "^19.0.0",
"react-arborist": "^3.16.0",
"react-colorful": "^5.8.0",
File diff suppressed because it is too large Load Diff
@@ -1,21 +1,22 @@
---
name: agc-web-game-development
description: Build or modify a playable web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work.
description: Build or modify a playable npm-managed Phaser 4 web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work.
---
# AGC Web Game Development
Implement the user's actual game request in the current project. Choose DOM, Canvas, WebGL, or a combination based on the game rather than a fixed code template.
Implement the user's actual game request in the current project as an npm-managed Phaser 4.2.1 game. Use Phaser scenes for gameplay and DOM only for deliberately external UI.
## Workflow
1. Read the existing `index.html`, `style.css`, and `game.js` before modifying an existing game.
2. Keep the entry self-contained and runnable from the AGC loopback preview. Avoid CDN-only dependencies and network-required runtime assets.
3. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
4. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
5. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art.
6. Avoid undefined animation callbacks, duplicate loops, stale event listeners, and state that survives restart unintentionally.
7. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
1. Read the existing package and source files before editing. New projects place `package.json`, `index.html`, `style.css`, and `game.js` under `game/`; existing root packages retain their layout. Run npm in that package directory (for example `npm --prefix game ci` and `npm --prefix game run build`).
2. Keep `package.json` and `package-lock.json` authoritative. Import Phaser with `import Phaser from 'phaser'`; do not copy a bundle, add an import map, or use a CDN. Other npm dependencies are allowed when the game needs them.
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
6. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art.
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
When implementing a new game loop or a broad gameplay revision, read `references/game-quality-checklist.md`.
@@ -1,4 +1,4 @@
interface:
display_name: "Web 游戏实现"
short_description: "在当前项目内设计、实现并验证可玩的 HTML、CSS 与 JavaScript 游戏"
short_description: "在当前项目内设计、实现并验证 npm 管理的 Phaser 4 游戏"
default_prompt: "Use $agc-web-game-development to build or modify the current playable web game."
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.10",
"version": "2026-08-26.12",
"skills": [
{
"name": "agc-project-structure",
@@ -57,7 +57,7 @@
"agents/openai.yaml",
"references/game-quality-checklist.md"
],
"sha256": "d7748d9ebf4324add0541daf16a2bbec09c4862b85af55bfb369c7f3b99aedff"
"sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a"
},
{
"name": "agc-browser-playtest",
@@ -1330,12 +1330,13 @@ fn codex_app_server_turn_start_params(
"approvalPolicy": approval_policy,
});
if workspace_mode.allows_workspace_writes() {
// Native project commands stay offline and remain bounded to the
// real game workspace.
// npm install/build must resolve project dependencies. Network access
// is enabled only for DirectProject; writableRoots keeps the file-write
// boundary at the real game workspace.
params["sandboxPolicy"] = serde_json::json!({
"type": "workspaceWrite",
"writableRoots": [workspace_path],
"networkAccess": false
"networkAccess": true
});
}
params
@@ -2119,6 +2120,9 @@ impl CodexAppServerConnection {
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject && llm.web_search_enabled {
command.env(DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV, "1");
}
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
command.env("npm_config_cache", workspace_path.join(".npm-cache"));
}
command
.env("CODEX_HOME", &isolated_codex_home)
.env("HOME", &isolated_os_home)
@@ -4522,7 +4526,7 @@ mod tests {
);
assert_eq!(
turn.pointer("/sandboxPolicy/networkAccess"),
Some(&serde_json::json!(false))
Some(&serde_json::json!(true))
);
let authority_paths = [
turn.get("cwd"),
@@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。新 Web 游戏使用 npm + VitePhaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts,完成后必须从 `dist/index.html` 试玩。 原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
@@ -72,6 +72,21 @@ fn direct_codex_game_outputs(root: &Path) -> Vec<(String, &'static str, &'static
(entry.to_string(), "game-entry", "text/html"),
(format!("{prefix}style.css"), "game-style", "text/css"),
(format!("{prefix}game.js"), "game-script", "text/javascript"),
(
format!("{prefix}package.json"),
"game-package",
"application/json",
),
(
format!("{prefix}package-lock.json"),
"game-lockfile",
"application/json",
),
(
format!("{prefix}vite.config.js"),
"game-build-config",
"text/javascript",
),
]
}
@@ -3184,7 +3199,14 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource {
fn direct_codex_output_fingerprint(root: &Path) -> String {
let mut hasher = Sha256::new();
for (local_path, _, _) in direct_codex_game_outputs(root) {
let mut paths: Vec<String> = direct_codex_game_outputs(root)
.into_iter()
.map(|(p, _, _)| p)
.collect();
paths.extend(direct_npm_source_paths(root));
paths.sort();
paths.dedup();
for local_path in paths {
hasher.update(local_path.as_bytes());
hasher.update([0]);
let path = root.join(local_path);
@@ -3200,6 +3222,70 @@ fn direct_codex_output_fingerprint(root: &Path) -> String {
format!("{:x}", hasher.finalize())
}
fn direct_npm_source_paths(root: &Path) -> Vec<String> {
let base = if root.join("package.json").is_file() {
root.to_path_buf()
} else if root.join("game/package.json").is_file() {
root.join("game")
} else {
return Vec::new();
};
let mut output = Vec::new();
let mut pending = vec![(base, 0usize)];
let mut inspected = 0usize;
while let Some((directory, depth)) = pending.pop() {
if depth > 16 || inspected >= 4096 {
break;
}
let Ok(entries) = fs::read_dir(directory) else {
continue;
};
for entry in entries.flatten() {
inspected += 1;
if inspected > 4096 {
break;
}
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
if name.starts_with('.')
|| matches!(
name.as_str(),
"node_modules"
| "dist"
| "target"
| "memory"
| "exports"
| "auth.json"
| "credentials.json"
| "game-creator.config.json"
| "game-creator.config.local.json"
)
{
continue;
}
let Ok(kind) = entry.file_type() else {
continue;
};
let path = entry.path();
if kind.is_dir() {
pending.push((path, depth + 1));
} else if kind.is_file()
&& matches!(
path.extension().and_then(|e| e.to_str()),
Some("js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "css" | "html" | "json")
)
{
if let Ok(relative) = path.strip_prefix(root) {
if let Some(value) = relative.to_str() {
output.push(value.replace('\\', "/"));
}
}
}
}
}
output.sort();
output
}
fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result<std::path::PathBuf, String> {
let revision = read_game_creator_agent_runtime_project_revision(root)
.map(|value| value.revision)
@@ -3631,6 +3717,34 @@ fn sync_direct_codex_project_file_projection_at(
)?;
registered += 1;
}
for local_path in direct_npm_source_paths(root) {
if direct_codex_game_outputs(root)
.iter()
.any(|(p, _, _)| p == &local_path)
{
continue;
}
if root.join(&local_path).is_file() {
let media_type = if local_path.ends_with(".css") {
"text/css"
} else if local_path.ends_with(".json") {
"application/json"
} else if local_path.ends_with(".html") {
"text/html"
} else {
"text/javascript"
};
register_local_asset_at(
root,
&local_path,
"game-source",
media_type,
"direct-codex",
direct_codex_generated_source(),
)?;
registered += 1;
}
}
if registered == 0 {
return Err("Codex 返回后没有可登记的游戏文件".to_string());
}
@@ -7343,7 +7457,7 @@ mod tests {
assert_eq!(manifest.versions.len(), 1);
assert_eq!(manifest.versions[0].version_id, "initial-1");
assert_eq!(manifest.versions[0].project_revision, 1);
assert_eq!(manifest.versions[0].resource_bindings.len(), 10);
assert_eq!(manifest.versions[0].resource_bindings.len(), 13);
for expected_path in DIRECT_CODEX_ART_ASSET_PATHS.into_iter().chain(
DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
.iter()
@@ -7380,7 +7494,7 @@ mod tests {
.expect("project revision");
assert_eq!(revision.revision, 2);
let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
assert_eq!(manifest.assets.len(), 10);
assert_eq!(manifest.assets.len(), 13);
assert_eq!(
manifest
.assets
@@ -7400,7 +7514,7 @@ mod tests {
manifest.versions[1].created_reason,
GameIterationVersionCreatedReason::AgentRevision
);
assert_eq!(manifest.versions[1].resource_bindings.len(), 10);
assert_eq!(manifest.versions[1].resource_bindings.len(), 13);
let unchanged_fingerprint = direct_codex_output_fingerprint(root.path());
sync_direct_codex_project_outputs_at(root.path(), Some(&unchanged_fingerprint))
@@ -8284,3 +8398,36 @@ mod tests {
);
}
}
#[cfg(test)]
mod npm_source_projection_tests {
use super::*;
#[test]
fn npm_sources_track_nested_modules_but_ignore_dependencies_and_private_files() {
for nested in [false, true] {
let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = if nested {
root.path().join("game")
} else {
root.path().to_path_buf()
};
fs::create_dir_all(source.join("src/scenes")).unwrap();
fs::create_dir_all(source.join("node_modules/demo")).unwrap();
fs::create_dir_all(source.join(".npm-cache")).unwrap();
fs::write(source.join("package.json"), "{}").unwrap();
fs::write(source.join("src/scenes/play.ts"), "export const value = 1;").unwrap();
fs::write(source.join("node_modules/demo/index.js"), "private dep").unwrap();
fs::write(source.join(".npm-cache/auth.json"), "private cache").unwrap();
let paths = direct_npm_source_paths(root.path());
let prefix = if nested { "game/" } else { "" };
assert!(paths.contains(&format!("{prefix}src/scenes/play.ts")));
assert!(!paths
.iter()
.any(|path| path.contains("node_modules") || path.contains(".npm-cache")));
let before = direct_codex_output_fingerprint(root.path());
fs::write(source.join("src/scenes/play.ts"), "export const value = 2;").unwrap();
assert_ne!(direct_codex_output_fingerprint(root.path()), before);
}
}
}
@@ -78,7 +78,7 @@ pub(crate) use draft_validation::{
validate_llm_agent_handoffs, validate_llm_game_draft, validate_non_placeholder_game_html,
validate_playable_game_html, validate_safe_game_html_runtime,
};
pub(crate) use draft_writer::write_local_game_draft_at;
pub(crate) use draft_writer::{ensure_legacy_json_generator_project, write_local_game_draft_at};
#[allow(unused_imports)]
pub(crate) use loop_orchestration::{
emit_agent_progress, game_creator_agent_llm_error_is_mud_points_insufficient,
@@ -9,8 +9,8 @@ pub(crate) fn write_local_game_draft_at(
if prompt.is_empty() {
return Err("创作想法不能为空".to_string());
}
ensure_legacy_json_generator_project(root)?;
validate_llm_game_draft(prompt, draft)?;
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let checkpoint = create_local_project_checkpoint_at(root)?;
let timestamp = unix_timestamp();
let title = draft.title.trim();
@@ -134,3 +134,38 @@ pub(crate) fn write_local_game_draft_at(
manifest,
})
}
pub(crate) fn ensure_legacy_json_generator_project(root: &Path) -> Result<(), String> {
if root.join("game/package.json").exists()
|| root.join("package.json").exists()
|| !root.join("game/index.html").is_file()
|| !root.join(".agent/manifest.json").is_file()
{
return Err("JSON Generator 仅支持已有的单文件 HTML 项目;新建游戏与 npm / Phaser 4 项目请使用 DirectProject 完成依赖安装、构建和试玩".to_string());
}
Ok(())
}
#[cfg(test)]
mod legacy_generator_tests {
use super::*;
#[test]
fn legacy_generator_rejects_npm_before_writing() {
let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
fs::create_dir_all(root.path().join("game")).unwrap();
fs::create_dir_all(root.path().join(".agent")).unwrap();
fs::write(root.path().join("game/index.html"), "legacy source").unwrap();
fs::write(root.path().join(".agent/manifest.json"), "{}").unwrap();
assert!(ensure_legacy_json_generator_project(root.path()).is_ok());
fs::write(root.path().join("game/package.json"), "{}").unwrap();
assert!(ensure_legacy_json_generator_project(root.path())
.unwrap_err()
.contains("DirectProject"));
assert_eq!(
fs::read_to_string(root.path().join("game/index.html")).unwrap(),
"legacy source"
);
assert!(!root.path().join(".agent/spec.md").exists());
}
}
@@ -44,6 +44,7 @@ pub(crate) async fn run_game_creator_agent_loop_at(
project_blackboard: &str,
progress: Option<&AgentProgressEmitter<'_>>,
) -> Result<GameCreatorAgentLoopResult, String> {
ensure_legacy_json_generator_project(root)?;
let spec_path = root.join(".agent/spec.md");
let findings_path = root.join(".agent/findings.md");
let run_id = format!("game-generate-draft-{}", unix_millis());
@@ -24,6 +24,7 @@ JSON schema:
}
gameHtml 规则:
- 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。
- 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。
- 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。
- JavaScript 不要 eval、Function、localStorage、fetch、WebSocket、ServiceWorker。
@@ -294,6 +294,7 @@ pub(crate) async fn generate_local_game_draft_at(
if prompt.is_empty() {
return Err("创作想法不能为空".to_string());
}
ensure_legacy_json_generator_project(root)?;
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let short_memory = read_optional_text(&root.join("memory/session.md"))?;
@@ -1489,14 +1489,12 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#"<!doctype html>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Genarrative Game Draft</title>
<style>
body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }
main { width: min(720px, calc(100vw - 32px)); }
</style>
</head>
<body><main>还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。</main></body>
<body><main id="game"></main><script type="module" src="/game.js"></script></body>
</html>
"#;
const DEFAULT_GAME_STYLE_CSS: &str = "body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }\nmain { width: min(720px, calc(100vw - 32px)); }\n";
const DEFAULT_GAME_SCRIPT_JS: &str = "import Phaser from 'phaser';\nimport './style.css';\n\nclass PlaceholderScene extends Phaser.Scene {\n create() { this.add.text(24, 24, '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。'); }\n}\n\nnew Phaser.Game({ type: Phaser.AUTO, width: 720, height: 420, parent: 'game', scene: PlaceholderScene });\n";
const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000";
const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json";
@@ -1405,9 +1405,14 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
.position(|candidate| candidate == needle)
}
/// 解析项目游戏根:项目根存在 `index.html` 时使用项目根(新布局),
/// 否则回退到旧布局的 `game/` 子目录。
/// npm 工程只预览构建结果;单 HTML 工程保留现有入口布局。
pub(crate) fn project_game_root(root: &Path) -> PathBuf {
if root.join("package.json").is_file() || root.join("dist/index.html").is_file() {
return root.join("dist");
}
if root.join("game/package.json").is_file() || root.join("game/dist/index.html").is_file() {
return root.join("game/dist");
}
if root.join("index.html").is_file() {
root.to_path_buf()
} else {
@@ -1419,9 +1424,24 @@ pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result<PathBu
let path = url_path.split('?').next().unwrap_or("/");
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
let relative = decoded.trim_start_matches('/');
if !relative.is_empty()
&& relative.split('/').any(|part| {
part.is_empty()
|| part == "."
|| part == ".."
|| part.contains('\\')
|| part.chars().any(char::is_control)
})
{
return Err("预览路径非法".to_string());
}
if relative.is_empty() {
return canonical_preview_path(root, &project_game_root(root).join("index.html"));
}
let game_root = project_game_root(root);
if game_root == root.join("dist") || game_root == root.join("game/dist") {
return canonical_preview_path(root, &game_root.join(relative));
}
let mut file_path = root.to_path_buf();
let mut parts = relative.split('/');
@@ -1456,6 +1476,43 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result<PathBuf, Stri
.canonicalize()
.map_err(|error| format!("预览文件不可用:{}: {error}", file_path.display()))?;
let relative_requested = file_path
.strip_prefix(root)
.map_err(|_| "预览路径越过项目目录".to_string())?;
let mut checked = root.to_path_buf();
for component in relative_requested.components() {
if !matches!(component, std::path::Component::Normal(_)) {
return Err("预览路径非法".to_string());
}
if component.as_os_str().to_str().is_some_and(|name| {
[
".agent",
".git",
".codex",
".hermes",
"node_modules",
"memory",
"exports",
"target",
]
.iter()
.any(|protected| name.eq_ignore_ascii_case(protected))
}) {
return Err("预览路径不能访问控制或依赖目录".to_string());
}
checked.push(component);
if fs::symlink_metadata(&checked)
.map_err(|error| error.to_string())?
.file_type()
.is_symlink()
{
return Err("预览路径不能包含符号链接".to_string());
}
}
if !canonical_file.is_file() {
return Err("预览路径必须是文件".to_string());
}
// New DirectProject layouts may use the project root itself as the web
// root. The old allow-list below only considered `game/` and `assets/`,
// which made a valid root `index.html` resolve to a 404 even though
@@ -1492,7 +1549,7 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result<PathBuf, Stri
}
}
for segment in ["game", "assets", "ui"] {
for segment in ["game", "assets", "ui", "dist"] {
let allowed_dir = root.join(segment);
let metadata = match fs::symlink_metadata(&allowed_dir) {
Ok(metadata) => metadata,
@@ -1586,6 +1643,43 @@ mod tests {
use super::*;
use std::fs;
#[test]
fn npm_preview_requires_build_and_prefers_bundled_assets() {
let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp");
fs::create_dir_all(&base).unwrap();
let root = tempfile::tempdir_in(base).unwrap();
fs::write(root.path().join("package.json"), "{}").unwrap();
fs::write(root.path().join("index.html"), "source").unwrap();
assert!(resolve_preview_path(root.path(), "/").is_err());
fs::create_dir_all(root.path().join("dist/assets")).unwrap();
fs::create_dir_all(root.path().join("assets")).unwrap();
fs::write(root.path().join("dist/index.html"), "<!doctype html>").unwrap();
fs::write(root.path().join("dist/assets/main.js"), "bundled").unwrap();
fs::write(root.path().join("assets/main.js"), "source").unwrap();
fs::write(root.path().join("assets/hero.png"), "image").unwrap();
assert_eq!(
resolve_preview_path(root.path(), "/assets/main.js").unwrap(),
root.path()
.join("dist/assets/main.js")
.canonicalize()
.unwrap()
);
assert!(resolve_preview_path(root.path(), "/assets/hero.png").is_err());
fs::create_dir_all(root.path().join("game")).unwrap();
fs::write(root.path().join("game/index.html"), "source").unwrap();
assert!(resolve_preview_path(root.path(), "/game/index.html").is_err());
assert!(resolve_preview_path(root.path(), "/assets/%2e%2e/index.html").is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(
root.path().join("index.html"),
root.path().join("dist/assets/leak.html"),
)
.unwrap();
assert!(resolve_preview_path(root.path(), "/assets/leak.html").is_err());
}
}
#[test]
fn root_layout_serves_root_entry_and_keeps_legacy_paths_available() {
let root = tempfile::tempdir().expect("create preview root");
@@ -4,26 +4,7 @@ pub(crate) fn export_local_project_package_at(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
validate_project_root(root)?;
ensure_project_export_package_dir(root, "game")?;
let game_index_path = resolve_local_project_path(root, "game/index.html")?;
if !game_index_path.is_file() {
return Err("导出试玩包前需要先生成 game/index.html".to_string());
}
let game_index_metadata = checked_export_package_metadata(&game_index_path, "game/index.html")?;
if !game_index_metadata.is_file() {
return Err("导出试玩包前需要先生成 game/index.html".to_string());
}
prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?;
let game_index = fs::read_to_string(&game_index_path)
.map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?;
if game_index.trim().is_empty() {
return Err("导出试玩包前 game/index.html 不能为空".to_string());
}
let lower_game_index = game_index.to_ascii_lowercase();
if !lower_game_index.contains("<html") && !lower_game_index.contains("<!doctype html") {
return Err("导出试玩包前 game/index.html 必须是 HTML 文档".to_string());
}
validate_game_html_smoke(&game_index)?;
super::verification::validate_project_game_entry(root)?;
ensure_project_export_package_dir(root, "exports")?;
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
if !readme_path.is_file() {
@@ -217,8 +198,22 @@ pub(crate) fn collect_project_export_package_files(
root: &Path,
) -> Result<Vec<(String, PathBuf, u64)>, String> {
let mut files = Vec::new();
collect_project_export_package_dir_files(root, "game", &mut files)?;
if resolve_local_project_path(root, "assets")?.exists() {
let game_root = crate::preview::project_game_root(root);
let built = game_root == root.join("dist") || game_root == root.join("game/dist");
if built {
let relative = relative_project_path(root, &game_root)?;
collect_project_export_package_dir_files(root, &relative, &mut files)?;
for (name, _, _) in &mut files {
*name = format!(
"game/{}",
name.strip_prefix(&format!("{relative}/"))
.ok_or("构建产物路径非法")?
);
}
} else {
collect_project_export_package_dir_files(root, "game", &mut files)?;
}
if !built && resolve_local_project_path(root, "assets")?.exists() {
collect_project_export_package_dir_files(root, "assets", &mut files)?;
}
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
@@ -312,3 +307,42 @@ pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result
}
normalize_relative_path(relative_path)
}
#[cfg(test)]
mod npm_export_tests {
use super::*;
#[test]
fn npm_package_contains_only_dist_and_publish_readme() {
let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp");
fs::create_dir_all(&base).unwrap();
let root = tempfile::tempdir_in(base).unwrap();
for directory in ["dist/assets", "assets", "exports", "node_modules", "game"] {
fs::create_dir_all(root.path().join(directory)).unwrap();
}
for file in [
"package.json",
"dist/index.html",
"dist/assets/main.js",
"assets/hero.png",
"exports/README.md",
"node_modules/private.js",
"game/source.js",
] {
fs::write(root.path().join(file), "test").unwrap();
}
let files = collect_project_export_package_files(root.path()).unwrap();
let names = files
.iter()
.map(|(name, _, _)| name.as_str())
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"exports/README.md",
"game/assets/main.js",
"game/index.html"
]
);
}
}
@@ -9,6 +9,32 @@ static MANIFEST_LOCK_OPEN_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
pub(crate) const GAME_CREATION_PROJECT_NAME_MAX_CHARS: usize = 80;
const DEFAULT_GAME_PACKAGE_JSON: &str = r#"{
"name": "agc-game",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"dependencies": {
"phaser": "4.2.1"
},
"devDependencies": {
"vite": "^6.2.0"
}
}
"#;
const DEFAULT_GAME_VITE_CONFIG: &str = r#"import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
base: './',
build: { outDir: 'dist', emptyOutDir: true },
});
"#;
pub(crate) fn normalize_game_creation_project_name(value: &str) -> Result<String, String> {
let name = value.trim();
if name.is_empty() {
@@ -445,6 +471,11 @@ pub(crate) fn init_local_game_project_at(
let name = normalize_game_creation_project_name(name)?;
prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?;
let create_npm_scaffold = !manifest_storage_exists(&root.join(".agent/manifest.json"))?
&& !root.join("index.html").exists()
&& !root.join("game/index.html").exists()
&& !root.join("package.json").exists()
&& !root.join("game/package.json").exists();
for relative in ["game", "assets", "memory", "memory/agents", "exports"] {
let path = root.join(relative);
ensure_game_creator_private_directory_tree(&path, "本地项目目录")?;
@@ -459,6 +490,32 @@ pub(crate) fn init_local_game_project_at(
"默认游戏入口",
)?;
}
if create_npm_scaffold {
for (relative, content, label) in [
(
"game/package.json",
DEFAULT_GAME_PACKAGE_JSON,
"游戏 npm 配置",
),
(
"game/package-lock.json",
include_str!("../../resources/agc-game-package-lock.json"),
"游戏 npm 锁文件",
),
(
"game/vite.config.js",
DEFAULT_GAME_VITE_CONFIG,
"游戏 Vite 配置",
),
("game/style.css", DEFAULT_GAME_STYLE_CSS, "游戏样式"),
("game/game.js", DEFAULT_GAME_SCRIPT_JS, "游戏入口脚本"),
] {
let path = root.join(relative);
if !prepare_game_creator_private_path_for_read(&path, false, label)? {
crate::write_game_creator_private_file(&path, content.as_bytes(), label)?;
}
}
}
let agent_db_path = root.join(".agent/agent.db");
if !agent_db_path.exists() {
@@ -1514,3 +1571,41 @@ pub(crate) fn trim_optional_string(value: Option<String>) -> Option<String> {
mod import_tests;
#[cfg(test)]
mod recovery_tests;
#[cfg(test)]
mod npm_scaffold_tests {
use super::*;
#[test]
fn npm_scaffold_uses_package_import_and_preserves_user_changes() {
let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
init_local_game_project_at(root.path(), "npm-scaffold", "游戏").unwrap();
let package: serde_json::Value =
serde_json::from_slice(&fs::read(root.path().join("game/package.json")).unwrap())
.unwrap();
assert_eq!(package["dependencies"]["phaser"], "4.2.1");
assert!(fs::read_to_string(root.path().join("game/game.js"))
.unwrap()
.contains("import Phaser from 'phaser'"));
assert!(root.path().join("game/package-lock.json").is_file());
fs::write(root.path().join("game/game.js"), "user source").unwrap();
init_local_game_project_at(root.path(), "npm-scaffold", "游戏").unwrap();
assert_eq!(
fs::read_to_string(root.path().join("game/game.js")).unwrap(),
"user source"
);
}
#[test]
fn npm_scaffold_does_not_migrate_an_existing_html_project() {
let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
fs::create_dir(root.path().join("game")).unwrap();
fs::write(root.path().join("game/index.html"), "existing html").unwrap();
init_local_game_project_at(root.path(), "existing-html", "已有游戏").unwrap();
assert!(!root.path().join("game/package.json").exists());
assert_eq!(
fs::read_to_string(root.path().join("game/index.html")).unwrap(),
"existing html"
);
}
}
@@ -400,6 +400,12 @@ pub(crate) fn build_project_resource_graph(
.push(resource_ids[0].clone());
}
let resources_by_manifest_asset_id = resource_ids_by_manifest_asset
.iter()
.filter(|(_, resource_ids)| resource_ids.len() == 1)
.map(|(asset_id, resource_ids)| (asset_id.clone(), resource_ids[0].clone()))
.collect::<BTreeMap<_, _>>();
let mut unresolved_reference_resource_ids = BTreeSet::new();
let mut reference_edge_by_id = BTreeMap::<String, ProjectResourceReferenceEdge>::new();
for (asset_id, target_resource_ids) in &resource_ids_by_manifest_asset {
@@ -418,16 +424,32 @@ pub(crate) fn build_project_resource_graph(
.filter(|value| !value.is_empty())
.collect::<BTreeSet<_>>()
{
let source_candidates = resources_by_external_id
let mut source_candidates = resources_by_external_id
.get(external_reference_id)
.map(Vec::as_slice)
.unwrap_or(&[]);
.unwrap_or(&[])
.iter()
.collect::<BTreeSet<_>>();
if let Some(referenced_asset_id) = external_reference_id
.strip_prefix("local-asset:")
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Some(source_resource_id) =
resources_by_manifest_asset_id.get(referenced_asset_id)
{
source_candidates.insert(source_resource_id);
}
}
if source_candidates.len() != 1 {
unresolved_reference_resource_ids.insert(external_reference_id.to_string());
continue;
}
let source_resource_id = &source_candidates[0];
if !resource_by_id.contains_key(source_resource_id)
let source_resource_id = source_candidates
.iter()
.next()
.expect("a non-empty candidate set must have one resource");
if !resource_by_id.contains_key(source_resource_id.as_str())
|| !resource_by_id.contains_key(target_resource_id)
{
continue;
@@ -438,7 +460,7 @@ pub(crate) fn build_project_resource_graph(
ProjectResourceReferenceEdge {
id,
kind: "asset-reference".to_string(),
source_resource_id: source_resource_id.clone(),
source_resource_id: source_resource_id.to_string(),
target_resource_id: target_resource_id.clone(),
cyclic: false,
},
@@ -835,6 +857,75 @@ mod tests {
);
}
#[test]
fn graph_resolves_local_asset_reference_identities() {
let manifest = manifest(
Vec::new(),
vec![
asset("source-1", None, &[], None),
asset(
"derivative-1",
Some("local-asset:derivative-1"),
&["local-asset:source-1"],
None,
),
],
);
let graph = build_project_resource_graph(
&manifest,
vec![
resource("asset:source-1", Some("source-1"), None),
resource("asset:derivative-1", Some("derivative-1"), None),
],
&[],
false,
);
assert_eq!(graph.reference_edges.len(), 1);
assert_eq!(
graph.reference_edges[0].source_resource_id,
"asset:source-1"
);
assert_eq!(
graph.reference_edges[0].target_resource_id,
"asset:derivative-1"
);
assert!(graph.unresolved_reference_resource_ids.is_empty());
assert!(graph
.connection_index
.iter()
.any(|index| index.resource_id == "asset:derivative-1"
&& index.upstream_reference_resource_ids == vec!["asset:source-1"]));
}
#[test]
fn graph_resolves_local_asset_identity_without_ambiguous_remote_duplicate() {
let manifest = manifest(
Vec::new(),
vec![
asset("source-1", Some("external-source"), &[], None),
asset(
"derivative-1",
Some("local-asset:derivative-1"),
&["local-asset:source-1"],
None,
),
],
);
let graph = build_project_resource_graph(
&manifest,
vec![
resource("asset:source-1", Some("source-1"), None),
resource("asset:derivative-1", Some("derivative-1"), None),
],
&[],
false,
);
assert_eq!(graph.reference_edges.len(), 1);
assert!(graph.unresolved_reference_resource_ids.is_empty());
}
#[test]
fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() {
let manifest = manifest(
@@ -3950,9 +3950,14 @@ fn commit_resource_edit_asset_internal(
})
.transpose()?;
let image_sequence_duration_ms = ledger.remote_sequence_duration_ms;
let asset_kind = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation {
"character-animation".to_string()
} else {
source.asset_kind.clone()
};
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
kind: source.asset_kind.clone(),
kind: asset_kind,
media_type: staged_media_type.to_string(),
local_path: relative_path.clone(),
image_sequence_frames,
@@ -8967,6 +8972,72 @@ mod tests {
);
}
#[test]
fn character_animation_commit_uses_animation_asset_kind() {
let directory = tempfile::tempdir().expect("create animation commit fixture");
let root = directory.path();
init_local_game_project_at(root, PROJECT_ID, "角色动画提交类型测试")
.expect("initialize project");
let uploaded = upload_local_asset_at(
root,
"character-source.png",
"image/png",
&resource_editor_test_png(),
)
.expect("upload source image");
let manifest = read_existing_manifest_for_project(root).expect("read source manifest");
let source_asset = manifest
.assets
.iter()
.find(|asset| asset.id == uploaded.id)
.cloned()
.expect("find source image");
let mut request = input(
root,
Uuid::new_v4().to_string(),
LocalProjectResourceEditKind::CharacterAnimation,
format!("asset:{}", source_asset.id),
);
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
let source = resolve_resource_edit_source(
root,
&read_existing_manifest_for_project(root).expect("reread manifest"),
&request,
)
.expect("resolve animation source");
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded);
ledger.staged_media_type = Some("video/mp4".to_string());
ledger.staged_extension = Some("mp4".to_string());
ledger.remote_sequence_duration_ms = Some(4_000);
write_resource_edit_staging(
root,
&request.operation_id,
b"\0\0\0\x18ftypisom\0\0\0\0isomiso2",
)
.expect("stage animation preview");
let result = commit_resource_edit_asset(
root,
&request,
&source,
&request.prompt,
&request.asset_name,
&mut ledger,
)
.expect("commit animation derivative");
let derivative = result.asset.expect("animation derivative");
assert_eq!(derivative.kind, "character-animation");
assert_eq!(
derivative.source.generation_kind.as_deref(),
Some("character-animation")
);
assert_eq!(derivative.image_sequence_duration_ms, Some(4_000));
}
#[test]
fn platform_session_switch_waits_until_local_asset_commit_finishes() {
let directory = tempfile::tempdir().expect("create commit account switch fixture");
@@ -14,16 +14,12 @@ pub(crate) fn run_limited_local_command_at(
return Err("项目目录必须是绝对路径".to_string());
}
let game_index_path = root.join("game/index.html");
prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?;
let html = fs::read_to_string(&game_index_path)
.map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?;
if !html.contains("<html") && !html.contains("<!doctype html") {
return Err("游戏入口不是 HTML 文档".to_string());
}
validate_game_html_smoke(&html)?;
let output = format!("通过:game/index.html{} 字节", html.len());
let (game_index_path, html) = validate_project_game_entry(root)?;
let output = format!(
"通过:{}{} 字节",
relative_project_path(root, &game_index_path)?,
html.len()
);
let log_path = root.join(".agent/logs/command.log");
let updated_at = unix_timestamp();
let line = format!("{updated_at} command.run_limited {command_id}: {output}\n");
@@ -49,6 +45,59 @@ pub(crate) fn run_limited_local_command_at(
})
}
pub(crate) fn validate_project_game_entry(root: &Path) -> Result<(PathBuf, String), String> {
let game_root = crate::preview::project_game_root(root);
let index = crate::preview::resolve_preview_path(root, "/")?;
prepare_game_creator_private_path_for_read(&index, false, "游戏入口")?;
let html = fs::read_to_string(&index).map_err(|error| format!("读取游戏入口失败:{error}"))?;
let lower = html.to_ascii_lowercase();
if !lower.contains("<html") && !lower.contains("<!doctype html") {
return Err("游戏入口不是 HTML 文档".to_string());
}
if game_root == root.join("dist") || game_root == root.join("game/dist") {
validate_built_game_references(root, &html)?;
} else {
validate_game_html_smoke(&html)?;
}
Ok((index, html))
}
fn validate_built_game_references(root: &Path, html: &str) -> Result<(), String> {
let tags = regex::Regex::new(r"(?is)<(?:script|link|img|audio|video|source)\b[^>]*>").unwrap();
let attributes =
regex::Regex::new(r#"(?is)\s+([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#)
.unwrap();
for tag in tags.find_iter(html) {
for attribute in attributes.captures_iter(tag.as_str()) {
let name = attribute.get(1).unwrap().as_str();
if !name.eq_ignore_ascii_case("src") && !name.eq_ignore_ascii_case("href") {
continue;
}
let value = attribute
.get(2)
.or_else(|| attribute.get(3))
.or_else(|| attribute.get(4))
.unwrap()
.as_str();
if value.is_empty()
|| value.starts_with('#')
|| value.starts_with("//")
|| value.contains(':')
{
continue;
}
let path = value.split(['?', '#']).next().unwrap_or(value);
let path = path.strip_prefix("./").unwrap_or(path);
crate::preview::resolve_preview_path(
root,
&format!("/{}", path.trim_start_matches('/')),
)
.map_err(|error| format!("构建入口引用不可用:{value}: {error}"))?;
}
}
Ok(())
}
pub(crate) const PROJECT_VERIFICATION_OUTPUT_MAX_BYTES: usize = 24 * 1024;
const PROJECT_VERIFICATION_PACKAGE_MAX_BYTES: u64 = 512 * 1024;
const PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS: u64 = 1;
@@ -840,3 +889,25 @@ pub(crate) fn enforce_project_auto_permission_policy(
}
Ok(())
}
#[cfg(test)]
mod npm_build_tests {
use super::*;
#[test]
fn built_smoke_checks_module_files_without_inline_canvas() {
let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp");
fs::create_dir_all(&base).unwrap();
let root = tempfile::tempdir_in(base).unwrap();
fs::create_dir_all(root.path().join("dist/assets")).unwrap();
fs::write(root.path().join("package.json"), "{}").unwrap();
let html = r#"<!doctype html><html><script type="module" src="./assets/main.js"></script><link href="./assets/main.css" rel="stylesheet"></html>"#;
fs::write(root.path().join("dist/index.html"), html).unwrap();
assert!(validate_built_game_references(root.path(), html).is_err());
fs::write(root.path().join("dist/assets/main.js"), "export {};").unwrap();
fs::write(root.path().join("dist/assets/main.css"), "body{}").unwrap();
assert!(validate_built_game_references(root.path(), html).is_ok());
let lazy_html = r#"<!doctype html><html><img data-src="later.png" data-href="missing.png" alt="src='not-a-reference.png'" src="./assets/main.js"></html>"#;
assert!(validate_built_game_references(root.path(), lazy_html).is_ok());
}
}
@@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#"
* 对每个 Component,直接完整返回其全部参数.
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
* 纯结构节点可以返回空数组并标为 NoProblem。
* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致
* 面向用户的 reason 使用中文。
"#;
#[derive(Clone, Debug, Deserialize, JsonSchema)]
@@ -14,3 +14,20 @@ export function collectUiNodeIds(root: UiNode): Set<NodeId> {
visitUiNodes(root, (node) => ids.add(node.id));
return ids;
}
export function findUiNode(root: UiNode, nodeId: NodeId): UiNode | null {
if (root.id === nodeId) return root;
for (const child of root.children) {
const found = findUiNode(child, nodeId);
if (found) return found;
}
return null;
}
export function countUiNodeDescendants(root: UiNode): number {
let count = 0;
for (const child of root.children) {
count += 1 + countUiNodeDescendants(child);
}
return count;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
export function ResourceBookTransitionLayer() {
return (
<div
className="game-resource-book-transition-layer"
aria-hidden="true"
inert
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,319 @@
export const RESOURCE_BOOK_MOTION_DURATION = 420;
const EASING = 'cubic-bezier(0.2, 0.78, 0.2, 1)';
type Snapshot = {
key: string;
rect: { left: number; top: number; width: number; height: number };
opacity: number;
content: HTMLElement;
};
export function resourceBookFlipTransform(
from: Snapshot['rect'],
to: Snapshot['rect'],
) {
return `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${from.width / to.width}, ${from.height / to.height})`;
}
function validRect(rect: Snapshot['rect']) {
return (
Object.values(rect).every(Number.isFinite) &&
rect.width > 0 &&
rect.height > 0
);
}
// Freeze presentation only. Clones cannot expose duplicate controls, replay
// media, or depend on CSS selectors belonging to the old ancestor tree.
function clonePresentation(
source: HTMLElement,
rect: Snapshot['rect'],
): HTMLElement {
const sourceStyle = getComputedStyle(source);
const width =
source.offsetWidth || Number.parseFloat(sourceStyle.width) || rect.width;
const height =
source.offsetHeight || Number.parseFloat(sourceStyle.height) || rect.height;
let transform = `scale(${rect.width / width}, ${rect.height / height})`;
if (typeof DOMMatrix !== 'undefined') {
let matrix = new DOMMatrix();
for (
let element: HTMLElement | null = source;
element;
element = element.parentElement
) {
const value = getComputedStyle(element).transform;
if (value && value !== 'none')
matrix = new DOMMatrix(value).multiply(matrix);
}
const { a, b, c, d } = matrix;
const left = Math.min(0, a * width, c * height, a * width + c * height);
const top = Math.min(0, b * width, d * height, b * width + d * height);
transform = `matrix(${a}, ${b}, ${c}, ${d}, ${-left}, ${-top})`;
}
const clone = source.cloneNode(true) as HTMLElement;
const originals = [source, ...source.querySelectorAll<HTMLElement>('*')];
const copies = [clone, ...clone.querySelectorAll<HTMLElement>('*')];
originals.forEach((original, index) => {
const copy = copies[index]!;
const style = getComputedStyle(original);
for (const property of Array.from(style)) {
copy.style.setProperty(property, style.getPropertyValue(property));
}
for (const attribute of Array.from(copy.attributes)) {
if (
attribute.name === 'id' ||
attribute.name.startsWith('data-') ||
attribute.name.startsWith('aria-') ||
attribute.name === 'autoplay'
) {
copy.removeAttribute(attribute.name);
}
}
copy.style.transition = 'none';
copy.style.animation = 'none';
copy.style.pointerEvents = 'none';
copy.setAttribute('tabindex', '-1');
if (copy instanceof HTMLMediaElement) {
copy.removeAttribute('src');
copy.querySelectorAll('source').forEach((item) => item.remove());
copy.autoplay = false;
}
if (original instanceof HTMLVideoElement && original.readyState >= 2) {
try {
const bitmap = document.createElement('canvas');
bitmap.width = original.videoWidth;
bitmap.height = original.videoHeight;
bitmap.getContext('2d')?.drawImage(original, 0, 0);
const image = document.createElement('img');
image.src = bitmap.toDataURL();
image.style.cssText = copy.style.cssText;
copy.replaceWith(image);
} catch {
// Cross-origin media may prohibit readback; retain the existing poster.
}
}
});
Object.assign(clone.style, {
position: 'absolute',
left: '0',
top: '0',
margin: '0',
transform,
transformOrigin: '0 0',
translate: 'none',
rotate: 'none',
scale: 'none',
width: `${width}px`,
height: `${height}px`,
opacity: '1',
visibility: 'visible',
});
return clone;
}
function capture(root: HTMLElement): Map<string, Snapshot> {
const result = new Map<string, Snapshot>();
const bounds = root.getBoundingClientRect();
const layer = root.querySelector<HTMLElement>(
'.game-resource-book-transition-layer',
);
const moving = layer?.querySelectorAll<HTMLElement>('[data-motion-snapshot]');
const elements = moving?.length
? Array.from(moving)
: Array.from(
root.querySelectorAll<HTMLElement>(
'.game-resource-book-scene .game-resource-card, .game-resource-book-scene-titlebar',
),
);
for (const element of elements) {
const box = element.getBoundingClientRect();
const rect = {
left: box.left - bounds.left,
top: box.top - bounds.top,
width: box.width,
height: box.height,
};
if (
!validRect(rect) ||
box.right < bounds.left ||
box.left > bounds.right ||
box.bottom < bounds.top ||
box.top > bounds.bottom
)
continue;
let opacity = 1;
let visible = true;
for (
let parent: HTMLElement | null = element;
parent && parent !== root;
parent = parent.parentElement
) {
const style = getComputedStyle(parent);
opacity *= Number(style.opacity || 1);
if (style.visibility === 'hidden' || style.display === 'none')
visible = false;
}
if (!visible || opacity <= 0) continue;
const key =
element.dataset.motionSnapshot ??
(element.dataset.resourceCardId
? `card:${element.dataset.resourceCardId}`
: `title:${element.dataset.resourceBookCategory}`);
result.set(key, {
key,
rect,
opacity,
content: clonePresentation(
element.dataset.motionSnapshot
? (element.firstElementChild as HTMLElement)
: element,
rect,
),
});
}
return result;
}
export type ResourceBookTransitionController = ReturnType<
typeof createResourceBookTransitionController
>;
export function createResourceBookTransitionController() {
let token = 0;
let source = new Map<string, Snapshot>();
let animations: Animation[] = [];
let layer: HTMLElement | null = null;
let root: HTMLElement | null = null;
let completion: (() => void) | null = null;
let observer: ResizeObserver | null = null;
let motionPreference: MediaQueryList | null = null;
const clear = () => {
animations.forEach((animation) => animation.cancel());
animations = [];
layer?.replaceChildren();
root?.removeAttribute('data-book-motion');
observer?.disconnect();
observer = null;
window.removeEventListener('resize', settle);
motionPreference?.removeEventListener?.('change', settle);
motionPreference = null;
completion = null;
};
const settle = () => {
const done = completion;
clear();
source.clear();
done?.();
};
const invalidate = () => {
token += 1;
clear();
source.clear();
return token;
};
return {
begin(manager: HTMLElement | null) {
const current = manager ? capture(manager) : new Map<string, Snapshot>();
invalidate();
root = manager;
source = current;
return token;
},
invalidate,
settle,
isCurrent: (expected: number) => token === expected,
play(manager: HTMLElement, expected: number, done: () => void) {
if (token !== expected) return;
root = manager;
completion = done;
layer = manager.querySelector('.game-resource-book-transition-layer');
motionPreference =
window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null;
if (
!layer ||
typeof layer.animate !== 'function' ||
motionPreference?.matches ||
source.size === 0
) {
settle();
return;
}
const target = capture(manager);
const keys = new Set([...source.keys(), ...target.keys()]);
if (!target.size) {
settle();
return;
}
try {
for (const key of keys) {
const from = source.get(key);
const to = target.get(key);
const item = to ?? from!;
const wrapper = document.createElement('div');
wrapper.dataset.motionSnapshot = key;
Object.assign(wrapper.style, {
position: 'absolute',
left: `${item.rect.left}px`,
top: `${item.rect.top}px`,
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transformOrigin: '0 0',
pointerEvents: 'none',
});
wrapper.append(item.content);
layer.append(wrapper);
const animation = wrapper.animate(
[
{
transform: resourceBookFlipTransform(
from?.rect ?? item.rect,
item.rect,
),
opacity: from?.opacity ?? 0,
},
{
transform: 'translate(0px, 0px) scale(1, 1)',
opacity: to?.opacity ?? 0,
},
],
{
duration: RESOURCE_BOOK_MOTION_DURATION,
easing: EASING,
fill: 'both',
},
);
animations.push(animation);
// Attach rejection handling immediately in case a later item fails
// before the aggregate completion handler has been installed.
void animation.finished.catch(() => undefined);
}
} catch {
settle();
return;
}
manager.setAttribute('data-book-motion', 'running');
// Canceled promises belong to their original generation.
void Promise.all(animations.map((animation) => animation.finished)).then(
() => {
if (token === expected) settle();
},
() => {
if (token === expected) settle();
},
);
window.addEventListener('resize', settle);
motionPreference?.addEventListener?.('change', settle);
if (typeof ResizeObserver !== 'undefined') {
const box = manager.getBoundingClientRect();
observer = new ResizeObserver(() => {
const next = manager.getBoundingClientRect();
if (next.width !== box.width || next.height !== box.height) settle();
});
observer.observe(manager);
}
},
dispose: invalidate,
};
}

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