Merge branch 'master' into feat/design_agent_simple
This commit is contained in:
@@ -51,6 +51,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
+10
-9
@@ -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
-1
@@ -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)
|
||||
@@ -4517,7 +4521,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 + Vite,Phaser 固定为 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"))?;
|
||||
|
||||
@@ -1467,14 +1467,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -835,3 +884,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ThemedModal } from '../../../components/modal/ThemedModal';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
|
||||
export type UiNodeDeleteRequest = {
|
||||
nodeId: NodeId;
|
||||
nodeLabel: string;
|
||||
descendantCount: number;
|
||||
};
|
||||
|
||||
type UiNodeDeleteConfirmModalProps = {
|
||||
request: UiNodeDeleteRequest | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: (nodeId: NodeId) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function UiNodeDeleteConfirmModal({
|
||||
request,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
disabled = false,
|
||||
}: UiNodeDeleteConfirmModalProps) {
|
||||
return (
|
||||
<ThemedModal
|
||||
open={request !== null}
|
||||
onClose={onCancel}
|
||||
ariaLabel="确认删除节点"
|
||||
panelClassName="w-[420px] rounded-2xl p-5"
|
||||
>
|
||||
<h2 className="m-0 text-base font-semibold">确认删除节点?</h2>
|
||||
<p className="text-sm leading-6 text-(--platform-text-soft)">
|
||||
将删除「{request?.nodeLabel ?? ''}」及其 {request?.descendantCount ?? 0}{' '}
|
||||
个后代节点,是否继续?
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (request) onConfirm(request.nodeId);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
|
||||
import { ChevronDown, Eye, EyeOff, Puzzle, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -9,16 +12,25 @@ import {
|
||||
adjustMoveIndex,
|
||||
type MoveHandler,
|
||||
type NodeRendererProps,
|
||||
type RowRendererProps,
|
||||
Tree,
|
||||
type TreeApi,
|
||||
} from 'react-arborist';
|
||||
|
||||
import {
|
||||
countUiNodeDescendants,
|
||||
findUiNode,
|
||||
} from '../../../features/ui-editor/treeUtils';
|
||||
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
|
||||
import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest';
|
||||
import type { UiEditorNodeFocusRequest } from '../model';
|
||||
import { UiNodeContextMenu } from './UiNodeContextMenu';
|
||||
import {
|
||||
UiNodeDeleteConfirmModal,
|
||||
type UiNodeDeleteRequest,
|
||||
} from './UiNodeDeleteConfirmModal';
|
||||
|
||||
type UiTreePanelProps = {
|
||||
root: UiNode | null;
|
||||
@@ -36,6 +48,38 @@ type UiTreePanelProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TreeListOuter = forwardRef<
|
||||
HTMLDivElement,
|
||||
HTMLAttributes<HTMLDivElement>
|
||||
>(function TreeListOuter({ style, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
style={{ ...style, overflowX: 'visible', overflowY: 'auto' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function TreeRowContainer<T>({
|
||||
node,
|
||||
innerRef,
|
||||
attrs,
|
||||
children,
|
||||
}: RowRendererProps<T>) {
|
||||
return (
|
||||
<div
|
||||
{...attrs}
|
||||
ref={innerRef}
|
||||
style={{ ...attrs.style, minWidth: 0 }}
|
||||
onFocus={(event) => event.stopPropagation()}
|
||||
onClick={node.handleClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
node,
|
||||
style,
|
||||
@@ -44,11 +88,17 @@ function TreeRow({
|
||||
isNodeVisible,
|
||||
onToggleNodeVisibility,
|
||||
onOpenContextMenu,
|
||||
onRequestDelete,
|
||||
canDelete,
|
||||
deleteDisabled,
|
||||
}: NodeRendererProps<UiNode> & {
|
||||
onSelectNode: (id: NodeId) => void;
|
||||
isNodeVisible: (nodeId: NodeId) => boolean;
|
||||
onToggleNodeVisibility: (id: NodeId) => void;
|
||||
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
|
||||
onRequestDelete: (id: NodeId) => void;
|
||||
canDelete: boolean;
|
||||
deleteDisabled: boolean;
|
||||
}) {
|
||||
const data = node.data;
|
||||
const nodeLabel = data.metadata.name || '未命名节点';
|
||||
@@ -56,8 +106,8 @@ function TreeRow({
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
className={`flex min-w-0 items-center gap-1 rounded-lg px-2 text-xs text-(--platform-text-strong) ${
|
||||
style={{ ...style, width: '100%', height: '100%', minWidth: 0 }}
|
||||
className={`relative flex min-w-0 items-center gap-1 rounded-lg px-2 pr-[68px] text-xs text-(--platform-text-strong) ${
|
||||
node.isSelected ? 'bg-orange-100 text-orange-900' : 'hover:bg-black/5'
|
||||
} ${isVisible ? '' : 'opacity-60'}`}
|
||||
onClick={() => {
|
||||
@@ -88,23 +138,53 @@ function TreeRow({
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate">{nodeLabel}</span>
|
||||
<span className="text-[10px] text-(--platform-text-soft)">
|
||||
{data.components.length}
|
||||
<span className="shrink-0 whitespace-nowrap" title={nodeLabel}>
|
||||
{nodeLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleNodeVisibility(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-0.5 rounded-md bg-black/5 px-1 py-0.5 text-[10px] leading-none text-(--platform-text-soft)"
|
||||
title={`${data.components.length} 个组件`}
|
||||
aria-label={`${data.components.length} 个组件`}
|
||||
>
|
||||
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
<Puzzle
|
||||
size={11}
|
||||
className="text-orange-600"
|
||||
fill="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{data.components.length}</span>
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-2 flex w-[52px] items-center justify-end gap-1 bg-inherit">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}”`}
|
||||
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleNodeVisibility(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除节点“${nodeLabel}”`}
|
||||
title={canDelete ? `删除节点“${nodeLabel}”` : '页面根节点不可删除'}
|
||||
disabled={!canDelete || deleteDisabled}
|
||||
aria-disabled={!canDelete || deleteDisabled}
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRequestDelete(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,11 +207,15 @@ export function UiTreePanel({
|
||||
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
|
||||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [treeHeight, setTreeHeight] = useState(0);
|
||||
const [treeWidth, setTreeWidth] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
nodeId: NodeId;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<
|
||||
(UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusRequest) return;
|
||||
@@ -150,6 +234,41 @@ export function UiTreePanel({
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const treeRows = useMemo(() => {
|
||||
if (!root) return [];
|
||||
const rows: Array<{ node: UiNode; depth: number }> = [];
|
||||
const visit = (node: UiNode, depth: number) => {
|
||||
rows.push({ node, depth });
|
||||
node.children.forEach((child) => visit(child, depth + 1));
|
||||
};
|
||||
visit(root, 0);
|
||||
return rows;
|
||||
}, [root]);
|
||||
|
||||
const treeContentWidth = useMemo(
|
||||
() =>
|
||||
treeRows.reduce((width, { node, depth }) => {
|
||||
const labelWidth = Math.max(
|
||||
56,
|
||||
(node.metadata.name || '未命名节点').length * 8,
|
||||
);
|
||||
return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52);
|
||||
}, 0),
|
||||
[treeRows],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = treeContainerRef.current;
|
||||
if (!container) return;
|
||||
const updateWidth = () => {
|
||||
setTreeWidth(Math.max(treeContentWidth, container.clientWidth));
|
||||
};
|
||||
updateWidth();
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(container);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [treeContentWidth]);
|
||||
|
||||
const handleMove: MoveHandler<UiNode> = ({
|
||||
dragIds,
|
||||
parentId,
|
||||
@@ -184,21 +303,55 @@ export function UiTreePanel({
|
||||
const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []);
|
||||
const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null;
|
||||
|
||||
const requestDelete = (nodeId: NodeId) => {
|
||||
if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
const treeId = treeIdForNode(nodeId);
|
||||
const node = findUiNode(root, nodeId);
|
||||
if (!treeId || !node) return;
|
||||
const descendantCount = countUiNodeDescendants(node);
|
||||
if (descendantCount === 0) {
|
||||
onDeleteNode(treeId, nodeId);
|
||||
return;
|
||||
}
|
||||
setPendingDelete({
|
||||
treeId,
|
||||
nodeId,
|
||||
nodeLabel: node.metadata.name || '未命名节点',
|
||||
descendantCount,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = (nodeId: NodeId) => {
|
||||
if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) {
|
||||
return;
|
||||
}
|
||||
const { treeId } = pendingDelete;
|
||||
setPendingDelete(null);
|
||||
onDeleteNode(treeId, nodeId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<div ref={treeContainerRef} className="min-h-0 flex-1">
|
||||
<div
|
||||
ref={treeContainerRef}
|
||||
className="relative min-h-0 flex-1 overflow-x-auto overflow-y-hidden"
|
||||
>
|
||||
{root && treeHeight > 0 ? (
|
||||
<Tree<UiNode>
|
||||
ref={treeApiRef}
|
||||
data={[root]}
|
||||
width="100%"
|
||||
width={treeWidth > 0 ? treeWidth : '100%'}
|
||||
outerElementType={TreeListOuter}
|
||||
height={treeHeight}
|
||||
rowHeight={34}
|
||||
indent={16}
|
||||
openByDefault
|
||||
renderRow={TreeRowContainer}
|
||||
selection={selectedNodeId ?? undefined}
|
||||
onSelect={(nodes) => {
|
||||
const selected = nodes[0]?.data;
|
||||
@@ -224,6 +377,12 @@ export function UiTreePanel({
|
||||
}}
|
||||
isNodeVisible={isNodePreviewVisible}
|
||||
onToggleNodeVisibility={onToggleNodeVisibility}
|
||||
onRequestDelete={requestDelete}
|
||||
canDelete={
|
||||
props.node.data.id !== root.id &&
|
||||
!pageRootIds.has(props.node.data.id)
|
||||
}
|
||||
deleteDisabled={isLocked}
|
||||
onOpenContextMenu={(event, nodeId) =>
|
||||
nodeId === root.id
|
||||
? undefined
|
||||
@@ -252,9 +411,15 @@ export function UiTreePanel({
|
||||
onClose={() => setContextMenu(null)}
|
||||
onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)}
|
||||
onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)}
|
||||
onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
) : null}
|
||||
<UiNodeDeleteConfirmModal
|
||||
request={pendingDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
disabled={isLocked}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ThemedModal } from '../../../components/modal/ThemedModal';
|
||||
import type { WorkflowCompletionNotice } from './workflowCompletionNotice';
|
||||
import { workflowStepLabel } from './workflowCompletionNotice';
|
||||
|
||||
export function WorkflowCompletionModal({
|
||||
notice,
|
||||
onClose,
|
||||
}: {
|
||||
notice: WorkflowCompletionNotice | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!notice) return null;
|
||||
const stepLabel = workflowStepLabel(notice.step);
|
||||
const outcomeLabel = notice.outcome === 'success' ? '完成' : '失败';
|
||||
return (
|
||||
<ThemedModal
|
||||
open
|
||||
onClose={onClose}
|
||||
ariaLabel={`${stepLabel}${outcomeLabel}`}
|
||||
panelClassName="w-[min(440px,calc(100vw-2rem))] rounded-2xl p-5"
|
||||
>
|
||||
<h2 className="m-0 text-base font-semibold">
|
||||
{stepLabel}
|
||||
{outcomeLabel}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-(--platform-text-soft)">
|
||||
{notice.message}
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
+116
-35
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
CANVAS_ZOOM_IN_FACTOR,
|
||||
CANVAS_ZOOM_OUT_FACTOR,
|
||||
type CanvasViewport,
|
||||
createPanDragState,
|
||||
type DragState,
|
||||
@@ -28,8 +30,14 @@ import type { Node } from '../../../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
|
||||
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
|
||||
import { UiNodeContextMenu } from '../UiNodeContextMenu';
|
||||
import {
|
||||
handlePreviewZoomKeyDown,
|
||||
isPreviewZoomInteractiveTarget,
|
||||
previewZoomUsesMetaModifier,
|
||||
} from './previewZoomKeyboard';
|
||||
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
|
||||
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
|
||||
import { ZoomPercentageInput } from './ZoomPercentageInput';
|
||||
|
||||
export function PreviewWorkspace({
|
||||
canvas,
|
||||
@@ -41,6 +49,8 @@ export function PreviewWorkspace({
|
||||
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
|
||||
const handledFocusRequestIdRef = useRef<number | null>(null);
|
||||
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
|
||||
const previewFocusedRef = useRef(false);
|
||||
const previewHoveredRef = useRef(false);
|
||||
const [viewport, setViewportState] = useState<CanvasViewport>(
|
||||
viewportRef.current,
|
||||
);
|
||||
@@ -150,6 +160,34 @@ export function PreviewWorkspace({
|
||||
);
|
||||
}, [logicalSize, setViewport]);
|
||||
|
||||
const scaleViewportFromCenter = useCallback(
|
||||
(nextScale: number) => {
|
||||
const element = viewportElementRef.current;
|
||||
const width = element?.clientWidth || canvasSize.width;
|
||||
const height = element?.clientHeight || canvasSize.height;
|
||||
setViewport(
|
||||
scaleViewportFromScreenPoint({
|
||||
viewport: viewportRef.current,
|
||||
nextScale,
|
||||
screenPoint: { x: width / 2, y: height / 2 },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[canvasSize.height, canvasSize.width, setViewport],
|
||||
);
|
||||
|
||||
const resetToActualSize = useCallback(() => {
|
||||
scaleViewportFromCenter(1);
|
||||
}, [scaleViewportFromCenter]);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR);
|
||||
}, [scaleViewportFromCenter]);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR);
|
||||
}, [scaleViewportFromCenter]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportElementRef.current;
|
||||
if (!element) return;
|
||||
@@ -224,6 +262,9 @@ export function PreviewWorkspace({
|
||||
),
|
||||
);
|
||||
};
|
||||
const usesMetaModifier = previewZoomUsesMetaModifier(
|
||||
window.navigator.platform,
|
||||
);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.code === 'Space' &&
|
||||
@@ -232,23 +273,21 @@ export function PreviewWorkspace({
|
||||
) {
|
||||
setSpaceHeld(true);
|
||||
}
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
if (event.key === '0') {
|
||||
event.preventDefault();
|
||||
fitToCanvas();
|
||||
} else if (event.key === '1' && logicalSize) {
|
||||
event.preventDefault();
|
||||
const element = viewportElementRef.current;
|
||||
const width = element?.clientWidth ?? 900;
|
||||
const height = element?.clientHeight ?? 640;
|
||||
setViewport(
|
||||
scaleViewportFromScreenPoint({
|
||||
viewport: viewportRef.current,
|
||||
nextScale: 1,
|
||||
screenPoint: { x: width / 2, y: height / 2 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
handlePreviewZoomKeyDown(
|
||||
event,
|
||||
{
|
||||
hasZoomableViewport: logicalSize !== null,
|
||||
isFocused: previewFocusedRef.current,
|
||||
isHovered: previewHoveredRef.current,
|
||||
usesMetaModifier,
|
||||
},
|
||||
{
|
||||
fit: fitToCanvas,
|
||||
resetToActualSize,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
},
|
||||
);
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') setSpaceHeld(false);
|
||||
@@ -262,9 +301,12 @@ export function PreviewWorkspace({
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
};
|
||||
}, [fitToCanvas, logicalSize, setViewport]);
|
||||
}, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]);
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) {
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
}
|
||||
if (event.button === 1 || (event.button === 0 && spaceHeld)) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
@@ -371,7 +413,27 @@ export function PreviewWorkspace({
|
||||
</div>
|
||||
<SharedCanvasViewport
|
||||
ref={viewportElementRef}
|
||||
className="size-full"
|
||||
className="size-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-orange-400"
|
||||
role="region"
|
||||
aria-label="UI 预览画布"
|
||||
tabIndex={0}
|
||||
onPointerEnter={() => {
|
||||
previewHoveredRef.current = true;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
previewHoveredRef.current = false;
|
||||
}}
|
||||
onFocus={() => {
|
||||
previewFocusedRef.current = true;
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
previewFocusedRef.current = false;
|
||||
}
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
@@ -448,21 +510,20 @@ export function PreviewWorkspace({
|
||||
<ZoomControls
|
||||
viewport={viewport}
|
||||
onFit={fitToCanvas}
|
||||
onScaleFromCenter={(nextScale) => {
|
||||
const element = viewportElementRef.current;
|
||||
const width = element?.clientWidth ?? canvasSize.width;
|
||||
const height = element?.clientHeight ?? canvasSize.height;
|
||||
setViewport(
|
||||
scaleViewportFromScreenPoint({
|
||||
viewport: viewportRef.current,
|
||||
nextScale,
|
||||
screenPoint: { x: width / 2, y: height / 2 },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onScaleFromCenter={scaleViewportFromCenter}
|
||||
>
|
||||
{(actions) => (
|
||||
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg">
|
||||
<div
|
||||
className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg"
|
||||
onMouseDown={(event) => {
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('button')
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded hover:bg-black/5"
|
||||
@@ -471,13 +532,33 @@ export function PreviewWorkspace({
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={25}
|
||||
max={200}
|
||||
step={5}
|
||||
value={Math.round(
|
||||
Number.parseInt(actions.displayPercent, 10),
|
||||
)}
|
||||
aria-label="画布缩放"
|
||||
className="h-1 w-28 accent-orange-500"
|
||||
onChange={(event) =>
|
||||
actions.zoomToDisplayScale(Number(event.target.value) / 100)
|
||||
}
|
||||
/>
|
||||
<ZoomPercentageInput
|
||||
displayPercent={actions.displayPercent}
|
||||
onCommit={(percent) =>
|
||||
actions.zoomToDisplayScale(percent / 100)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-14 rounded px-2 py-1 text-[10px] tabular-nums hover:bg-black/5"
|
||||
aria-label={`适配画布,当前 ${actions.displayPercent}`}
|
||||
className="rounded px-2 py-1 text-[10px] hover:bg-black/5"
|
||||
aria-label="适配画布"
|
||||
onClick={actions.fit}
|
||||
>
|
||||
{actions.displayPercent}
|
||||
适配
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -90,7 +90,7 @@ function RenderNode({
|
||||
selectedNodeId,
|
||||
resources,
|
||||
onSelectNode,
|
||||
consumeNodeClick = () => false,
|
||||
consumeNodeClick,
|
||||
onNodeContextMenu,
|
||||
onNodePointerDown,
|
||||
onNodePointerMove,
|
||||
@@ -107,6 +107,8 @@ function RenderNode({
|
||||
isRoot?: boolean;
|
||||
parentContainer?: UiNode['layout']['container'];
|
||||
}) {
|
||||
const hasClickGestureConsumer = consumeNodeClick !== undefined;
|
||||
const consumeClick = consumeNodeClick ?? (() => false);
|
||||
const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS;
|
||||
if (hiddenNodeIds.has(node.id)) return null;
|
||||
const previewTransform = activePreviewTransforms.get(node.id);
|
||||
@@ -158,11 +160,12 @@ function RenderNode({
|
||||
hasDirectPointerGesture
|
||||
? (event) => {
|
||||
event.stopPropagation();
|
||||
consumeNodeClick();
|
||||
if (consumeClick()) return;
|
||||
if (hasClickGestureConsumer) onSelectNode(node.id);
|
||||
}
|
||||
: (event) => {
|
||||
event.stopPropagation();
|
||||
if (consumeNodeClick()) return;
|
||||
if (consumeClick()) return;
|
||||
onSelectNode(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const MIN_ZOOM_PERCENT = 25;
|
||||
const MAX_ZOOM_PERCENT = 200;
|
||||
|
||||
function displayPercentToValue(displayPercent: string) {
|
||||
return displayPercent.replace('%', '');
|
||||
}
|
||||
|
||||
export function ZoomPercentageInput({
|
||||
displayPercent,
|
||||
onCommit,
|
||||
}: {
|
||||
displayPercent: string;
|
||||
onCommit: (percent: number) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() =>
|
||||
displayPercentToValue(displayPercent),
|
||||
);
|
||||
const draftRef = useRef(draft);
|
||||
const isEditingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingRef.current) {
|
||||
setDraft(displayPercentToValue(displayPercent));
|
||||
draftRef.current = displayPercentToValue(displayPercent);
|
||||
}
|
||||
}, [displayPercent]);
|
||||
|
||||
const commit = () => {
|
||||
const currentPercent = Number.parseFloat(
|
||||
displayPercentToValue(displayPercent),
|
||||
);
|
||||
const parsed = Number.parseFloat(draftRef.current);
|
||||
const nextPercent = Number.isFinite(parsed)
|
||||
? Math.min(MAX_ZOOM_PERCENT, Math.max(MIN_ZOOM_PERCENT, parsed))
|
||||
: currentPercent;
|
||||
setDraft(String(nextPercent));
|
||||
draftRef.current = String(nextPercent);
|
||||
isEditingRef.current = false;
|
||||
if (nextPercent !== currentPercent) {
|
||||
onCommit(nextPercent);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex min-w-14 items-center justify-center rounded px-1 py-1 hover:bg-black/5">
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_ZOOM_PERCENT}
|
||||
max={MAX_ZOOM_PERCENT}
|
||||
step={5}
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
aria-label={`画布缩放,当前 ${displayPercent}`}
|
||||
className="w-10 bg-transparent text-center text-[10px] tabular-nums outline-none"
|
||||
onFocus={() => {
|
||||
isEditingRef.current = true;
|
||||
const nextDraft = displayPercentToValue(displayPercent);
|
||||
setDraft(nextDraft);
|
||||
draftRef.current = nextDraft;
|
||||
}}
|
||||
onChange={(event) => {
|
||||
draftRef.current = event.target.value;
|
||||
setDraft(event.target.value);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden="true" className="text-[10px] tabular-nums">
|
||||
%
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user