合并master并保留双侧决策记录
合并 origin/master 的 UI 编辑器、GDD 审批与前端修复。 保留本分支 LLM Router、Direct 过程卡与私有路径相关实现和决策记录。 解决 decision-log 文档冲突,完整保留双方新增决策条目。
This commit is contained in:
+35
@@ -1715,6 +1715,7 @@ dependencies = [
|
||||
"image",
|
||||
"jsonschema",
|
||||
"libc",
|
||||
"maud",
|
||||
"nalgebra",
|
||||
"oxc_allocator",
|
||||
"oxc_ast",
|
||||
@@ -2734,6 +2735,28 @@ dependencies = [
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maud"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"maud_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maud_macros"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"proc-macro2-diagnostics",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
@@ -3967,6 +3990,18 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2-diagnostics"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
|
||||
@@ -58,6 +58,7 @@ unicode-normalization = "0.1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
maud = "0.27.0"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
+4
@@ -19,6 +19,10 @@ Implement the user's actual game request in the current project. Choose DOM, Can
|
||||
|
||||
Call `agc_read_skill_resource` with `skillName="agc-web-game-development"` and `relativePath="references/game-quality-checklist.md"` when implementing a new game loop or a broad gameplay revision.
|
||||
|
||||
# Notes
|
||||
* UI Editor's JSON formated is a special kind asset; Never edit it or read it directly. User can export to `ui/generated-<digest>.js` and want u to use it;
|
||||
Read it instead, the doc embedded will instruct you how to use it.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report the files changed, observable gameplay behavior, real validation performed, and any remaining limitation. Do not report a test or browser pass that did not run.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.4",
|
||||
"version": "2026-08-26.9",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-project-structure",
|
||||
@@ -61,7 +61,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/game-quality-checklist.md"
|
||||
],
|
||||
"sha256": "b54646fc83eb48ffa270a726c63cdea5f05099d7990ec99024b00e255fb38d6c"
|
||||
"sha256": "b197a242b75dc5e39739693e793acd25ac62dd2c72bc264305797936da47f356"
|
||||
},
|
||||
{
|
||||
"name": "agc-browser-playtest",
|
||||
|
||||
@@ -1254,7 +1254,7 @@ mod tests {
|
||||
"agc_import_account_assets",
|
||||
"agc_create_or_derive_resource",
|
||||
"agc_remove_background",
|
||||
"agc_browser_playtest"
|
||||
"agc_browser_playtest",
|
||||
]
|
||||
);
|
||||
let serialized = specs.to_string();
|
||||
|
||||
@@ -2403,13 +2403,17 @@ pub(super) fn try_acquire_game_creator_agent_runtime_task_lock_with_wait(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
) -> Result<Option<AgentRuntimeTaskLock>, String> {
|
||||
for attempt in 0..25 {
|
||||
// Runtime state transitions can persist several audit projections while holding
|
||||
// the lane lock. Keep the wait bounded, but allow a slow CI/disk-backed
|
||||
// transition to finish before reporting a false busy error.
|
||||
const MAX_ATTEMPTS: usize = 100;
|
||||
for attempt in 0..MAX_ATTEMPTS {
|
||||
if let Some(runtime_lock) =
|
||||
try_acquire_game_creator_agent_runtime_task_lock(root, agent_id)?
|
||||
{
|
||||
return Ok(Some(runtime_lock));
|
||||
}
|
||||
if attempt < 24 {
|
||||
if attempt + 1 < MAX_ATTEMPTS {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,15 @@ fn save_ui_design_state(
|
||||
ui_editor::persistence::save_ui_design_state_at(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn generate_ui_design_code(
|
||||
input: ui_editor::persistence::GenerateUiDesignCodeInput,
|
||||
) -> Result<ui_editor::persistence::GenerateUiDesignCodeResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "file.write")?;
|
||||
ui_editor::persistence::generate_ui_design_code_at(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn ensure_ui_design_resource_for_prototype(
|
||||
input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput,
|
||||
@@ -2481,6 +2490,7 @@ fn main() {
|
||||
bind_components,
|
||||
load_ui_design_state,
|
||||
save_ui_design_state,
|
||||
generate_ui_design_code,
|
||||
ensure_ui_design_resource_for_prototype,
|
||||
generate_platform_art_asset,
|
||||
open_canvas_project,
|
||||
|
||||
@@ -1429,7 +1429,7 @@ pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result<PathBu
|
||||
if first.is_empty() || first == "." || first == ".." || first.contains('\\') {
|
||||
return Err("预览路径非法".to_string());
|
||||
}
|
||||
if first == "game" || first == "assets" {
|
||||
if first == "game" || first == "assets" || first == "ui" {
|
||||
file_path.push(first);
|
||||
} else {
|
||||
// `/` serves the resolved game entry (project-root index.html or the
|
||||
@@ -1492,7 +1492,7 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result<PathBuf, Stri
|
||||
}
|
||||
}
|
||||
|
||||
for segment in ["game", "assets"] {
|
||||
for segment in ["game", "assets", "ui"] {
|
||||
let allowed_dir = root.join(segment);
|
||||
let metadata = match fs::symlink_metadata(&allowed_dir) {
|
||||
Ok(metadata) => metadata,
|
||||
@@ -1517,7 +1517,7 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result<PathBuf, Stri
|
||||
return Ok(canonical_file);
|
||||
}
|
||||
}
|
||||
Err("预览路径只能访问真实 game/ 或 assets/ 目录".to_string())
|
||||
Err("预览路径只能访问真实 game/、assets/ 或 ui/ 目录".to_string())
|
||||
}
|
||||
|
||||
fn percent_decode_path(path: &str) -> Option<String> {
|
||||
@@ -1659,4 +1659,30 @@ mod tests {
|
||||
assert!(resolve_preview_path(root.path(), "/.agent/secret.json").is_err());
|
||||
assert!(resolve_preview_path(root.path(), "/memory/private.md").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_layout_serves_root_ui_modules() {
|
||||
let root = tempfile::tempdir().expect("create preview root");
|
||||
fs::create_dir_all(root.path().join("game")).expect("create game directory");
|
||||
fs::create_dir_all(root.path().join("ui")).expect("create ui directory");
|
||||
fs::write(root.path().join("game/index.html"), "<!doctype html>")
|
||||
.expect("write game entry");
|
||||
fs::write(root.path().join("ui/generated-x.js"), "export {};")
|
||||
.expect("write generated module");
|
||||
|
||||
let expected = root
|
||||
.path()
|
||||
.join("ui/generated-x.js")
|
||||
.canonicalize()
|
||||
.expect("canonical generated module");
|
||||
assert_eq!(
|
||||
resolve_preview_path(root.path(), "/ui/generated-x.js").unwrap(),
|
||||
expected
|
||||
);
|
||||
|
||||
let response = build_preview_response(root.path(), "GET", "/ui/generated-x.js");
|
||||
let response_text = String::from_utf8_lossy(&response);
|
||||
assert!(response_text.starts_with("HTTP/1.1 200 OK\r\n"));
|
||||
assert!(response_text.contains("export {};"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +439,26 @@ pub(super) fn ping_external_agent_runner(
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(super) fn ping_external_agent_runner_with_timeout(
|
||||
endpoint: &ExternalAgentRunnerEndpoint,
|
||||
timeout: Duration,
|
||||
) -> Result<(), String> {
|
||||
if timeout.is_zero() {
|
||||
return Err("Agent Runner ping 超时预算已耗尽".to_string());
|
||||
}
|
||||
send_external_agent_runner_request_with_protocol_and_id_and_timeouts(
|
||||
endpoint,
|
||||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||||
random_identifier(b"genarrative-agent-runner-start-ping-id")?,
|
||||
"runner.ping",
|
||||
ExternalAgentRunnerRequestParams::default(),
|
||||
EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT.min(timeout),
|
||||
timeout,
|
||||
timeout,
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(super) fn retire_incompatible_external_agent_runner(
|
||||
endpoint_path: &Path,
|
||||
endpoint: &ExternalAgentRunnerEndpoint,
|
||||
@@ -1251,10 +1271,15 @@ pub(super) fn wait_for_external_agent_runner(
|
||||
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
|
||||
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err("外部 Agent Runner 未在启动期限内就绪".to_string());
|
||||
}
|
||||
if let Some(endpoint) =
|
||||
read_current_external_agent_runner_endpoint(&endpoint_path, executable_fingerprint)
|
||||
{
|
||||
if ping_external_agent_runner(&endpoint).is_ok() {
|
||||
let ping_timeout = EXTERNAL_AGENT_RUNNER_IO_TIMEOUT.min(remaining);
|
||||
if ping_external_agent_runner_with_timeout(&endpoint, ping_timeout).is_ok() {
|
||||
return Ok(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +282,18 @@ fn runner_start_timeout_covers_cold_debug_binary_fingerprinting() {
|
||||
assert!(EXTERNAL_AGENT_RUNNER_START_TIMEOUT >= Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_runner_ping_timeout_rejects_an_exhausted_budget() {
|
||||
let endpoint = test_endpoint(
|
||||
"startup-ping-timeout-token",
|
||||
"startup-ping-timeout-boot",
|
||||
31_338,
|
||||
);
|
||||
let error = ping_external_agent_runner_with_timeout(&endpoint, Duration::ZERO)
|
||||
.expect_err("an exhausted startup budget must not attempt a runner ping");
|
||||
assert!(error.contains("超时预算已耗尽"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_runner_drain_deadline_precedes_gui_hard_kill_deadline() {
|
||||
assert!(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
use maud::{Markup, PreEscaped};
|
||||
|
||||
pub(super) fn font_face_rule(
|
||||
font: &crate::ui_editor::resource::font::FontAsset,
|
||||
family: &str,
|
||||
) -> Result<String, String> {
|
||||
let path = asset_url(&font.path)?;
|
||||
Ok(format!("@font-face{{font-family:'{family}';src:url('{path}') format('{}');font-style:{};font-weight:{};}}", font.metadata.format.css_format(), if font.metadata.italic { "italic" } else { "normal" }, font.metadata.weight))
|
||||
}
|
||||
|
||||
pub(super) fn html_comment(label: &str, value: serde_json::Value) -> Markup {
|
||||
let mut text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string());
|
||||
while text.contains("--") {
|
||||
text = text.replace("--", "- -");
|
||||
}
|
||||
PreEscaped(format!("<!-- {label}:\n{text}\n-->"))
|
||||
}
|
||||
|
||||
pub(super) fn asset_url(path: &str) -> Result<String, String> {
|
||||
let path = path.trim();
|
||||
if path.is_empty()
|
||||
|| path.starts_with('/')
|
||||
|| path.contains('\\')
|
||||
|| path.split('/').any(|part| part == "..")
|
||||
|| path.chars().any(|character| {
|
||||
character.is_control() || matches!(character, '<' | '>' | '\'' | '"' | '`' | '(' | ')')
|
||||
})
|
||||
{
|
||||
return Err(format!("资源路径无效:{path}"));
|
||||
}
|
||||
Ok(format!("/{path}"))
|
||||
}
|
||||
|
||||
pub(super) fn finite(value: f32, field: &str) -> Result<f32, String> {
|
||||
if value.is_finite() {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(format!("{field} 必须是有限数值"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finite_positive(value: f32, field: &str) -> Result<f32, String> {
|
||||
finite(value, field).and_then(|v| {
|
||||
if v > 0.0 {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(format!("{field} 必须大于 0"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn trim_float(value: f32) -> String {
|
||||
let text = format!("{value:.4}");
|
||||
text.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::font_face_rule;
|
||||
use crate::ui_editor::resource::font::{FontAsset, FontAssetMetadata, FontFormat};
|
||||
use crate::ui_editor::utils::FontAssetId;
|
||||
|
||||
fn font(format: FontFormat) -> FontAsset {
|
||||
FontAsset {
|
||||
asset_id: FontAssetId::new("font-main").expect("valid font id"),
|
||||
metadata: FontAssetMetadata {
|
||||
family_name: "测试字体".to_string(),
|
||||
face_name: "Regular".to_string(),
|
||||
weight: 400,
|
||||
italic: false,
|
||||
format,
|
||||
source_file_name: format!("font.{}", format.extension()),
|
||||
},
|
||||
path: format!("assets/font.{}", format.extension()),
|
||||
content_sha256: "0".repeat(64),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_face_rule_uses_standard_css_format_names() {
|
||||
let truetype = font_face_rule(&font(FontFormat::TrueType), "ui-font").unwrap();
|
||||
let opentype = font_face_rule(&font(FontFormat::OpenType), "ui-font").unwrap();
|
||||
assert!(truetype.contains("format('truetype')"));
|
||||
assert!(opentype.contains("format('opentype')"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hex_id(value: &str) -> String {
|
||||
value
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use crate::ui_editor::component::image::{
|
||||
FillMethod, HorizontalFillOrigin, ImageType, Radial180Origin, Radial360Origin, Radial90Origin,
|
||||
VerticalFillOrigin,
|
||||
};
|
||||
use crate::ui_editor::html_renderer::assets::{finite_positive, trim_float};
|
||||
|
||||
const UI_SCALE: &str = "var(--ui-scale, 1)";
|
||||
|
||||
pub(in crate::ui_editor::html_renderer) fn image_styles(
|
||||
image_type: &ImageType,
|
||||
sprite: &crate::ui_editor::resource::sprite::SpriteAsset,
|
||||
src: &str,
|
||||
) -> Result<(String, Option<String>), String> {
|
||||
match image_type {
|
||||
ImageType::Simple { preserve_aspect } => Ok((
|
||||
"position:absolute;inset:0;overflow:hidden;".to_string(),
|
||||
Some(format!(
|
||||
"width:100%;height:100%;display:block;object-fit:{};",
|
||||
if *preserve_aspect { "contain" } else { "fill" }
|
||||
)),
|
||||
)),
|
||||
ImageType::Filled {
|
||||
preserve_aspect,
|
||||
method,
|
||||
amount,
|
||||
} => {
|
||||
let mut image = format!(
|
||||
"width:100%;height:100%;display:block;object-fit:{};",
|
||||
if *preserve_aspect { "contain" } else { "fill" }
|
||||
);
|
||||
if let Some(clip) = fill_clip_path(method, amount.clamp(0.0, 1.0)) {
|
||||
if clip.starts_with("conic-gradient") {
|
||||
image.push_str(&format!("mask-image:{clip};-webkit-mask-image:{clip};"));
|
||||
} else {
|
||||
image.push_str(&format!("clip-path:{clip};"));
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
"position:absolute;inset:0;overflow:hidden;".to_string(),
|
||||
Some(image),
|
||||
))
|
||||
}
|
||||
ImageType::Tiled {
|
||||
pixels_per_unit_multiplier,
|
||||
..
|
||||
} => {
|
||||
// TODO: Tiled.fill_center=false 当前暂不支持,保持整块 repeat fallback。
|
||||
let w = finite_positive(
|
||||
sprite.pixel_size.x
|
||||
/ (sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get()),
|
||||
"tile width",
|
||||
)?;
|
||||
let h = finite_positive(
|
||||
sprite.pixel_size.y
|
||||
/ (sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get()),
|
||||
"tile height",
|
||||
)?;
|
||||
Ok((format!("position:absolute;inset:0;background-image:url(\"{src}\");background-repeat:repeat;background-position:top left;background-size:calc({}px * {}) calc({}px * {});overflow:hidden;", trim_float(w), UI_SCALE, trim_float(h), UI_SCALE), None))
|
||||
}
|
||||
ImageType::Sliced {
|
||||
fill_center,
|
||||
pixels_per_unit_multiplier,
|
||||
} => {
|
||||
let border = sprite.border();
|
||||
let scale = finite_positive(
|
||||
sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get(),
|
||||
"slice scale",
|
||||
)?;
|
||||
let widths = [
|
||||
border.top() as f32 / scale,
|
||||
border.right() as f32 / scale,
|
||||
border.bottom() as f32 / scale,
|
||||
border.left() as f32 / scale,
|
||||
];
|
||||
Ok((format!("position:absolute;inset:0;overflow:hidden;border-style:solid;border-width:calc({}px * {}) calc({}px * {}) calc({}px * {}) calc({}px * {});border-image-source:url(\"{src}\");border-image-slice:{} {} {} {}{};border-image-width:calc({}px * {}) calc({}px * {}) calc({}px * {}) calc({}px * {});border-image-repeat:stretch;", trim_float(widths[0]), UI_SCALE, trim_float(widths[1]), UI_SCALE, trim_float(widths[2]), UI_SCALE, trim_float(widths[3]), UI_SCALE, border.top(), border.right(), border.bottom(), border.left(), if *fill_center { " fill" } else { "" }, trim_float(widths[0]), UI_SCALE, trim_float(widths[1]), UI_SCALE, trim_float(widths[2]), UI_SCALE, trim_float(widths[3]), UI_SCALE), None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_clip_path(method: &FillMethod, amount: f32) -> Option<String> {
|
||||
if amount >= 1.0 {
|
||||
return None;
|
||||
}
|
||||
if amount <= 0.0 {
|
||||
return Some("inset(0 0 0 100%)".to_string());
|
||||
}
|
||||
match method {
|
||||
FillMethod::Horizontal(HorizontalFillOrigin::Left) => Some(format!(
|
||||
"inset(0 {}% 0 0)",
|
||||
trim_float((1.0 - amount) * 100.0)
|
||||
)),
|
||||
FillMethod::Horizontal(HorizontalFillOrigin::Right) => Some(format!(
|
||||
"inset(0 0 0 {}%)",
|
||||
trim_float((1.0 - amount) * 100.0)
|
||||
)),
|
||||
FillMethod::Vertical(VerticalFillOrigin::Top) => Some(format!(
|
||||
"inset(0 0 {}% 0)",
|
||||
trim_float((1.0 - amount) * 100.0)
|
||||
)),
|
||||
FillMethod::Vertical(VerticalFillOrigin::Bottom) => Some(format!(
|
||||
"inset({}% 0 0 0)",
|
||||
trim_float((1.0 - amount) * 100.0)
|
||||
)),
|
||||
FillMethod::Radial90 { origin, clockwise } => Some(conic_mask(
|
||||
radial_origin_angle_90(*origin) - if *clockwise { 90.0 } else { 0.0 },
|
||||
*clockwise,
|
||||
amount,
|
||||
90.0,
|
||||
)),
|
||||
FillMethod::Radial180 { origin, clockwise } => Some(conic_mask(
|
||||
radial_origin_angle_180(*origin) - if *clockwise { 90.0 } else { 0.0 },
|
||||
*clockwise,
|
||||
amount,
|
||||
180.0,
|
||||
)),
|
||||
FillMethod::Radial360 { origin, clockwise } => Some(conic_mask(
|
||||
radial_origin_angle_360(*origin) - if *clockwise { 90.0 } else { 0.0 },
|
||||
*clockwise,
|
||||
amount,
|
||||
360.0,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fill_clip_path, FillMethod, Radial180Origin, Radial360Origin, Radial90Origin};
|
||||
|
||||
#[test]
|
||||
fn clockwise_radial_180_and_360_start_ninety_degrees_before_origin() {
|
||||
let radial_180 = fill_clip_path(
|
||||
&FillMethod::Radial180 {
|
||||
origin: Radial180Origin::Top,
|
||||
clockwise: true,
|
||||
},
|
||||
0.5,
|
||||
)
|
||||
.unwrap();
|
||||
let radial_360 = fill_clip_path(
|
||||
&FillMethod::Radial360 {
|
||||
origin: Radial360Origin::Right,
|
||||
clockwise: true,
|
||||
},
|
||||
0.25,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(radial_180.contains("from -90deg"));
|
||||
assert!(radial_360.contains("from 0deg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counter_clockwise_radial_start_angles_remain_unchanged() {
|
||||
let radial_180 = fill_clip_path(
|
||||
&FillMethod::Radial180 {
|
||||
origin: Radial180Origin::Top,
|
||||
clockwise: false,
|
||||
},
|
||||
0.5,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(radial_180.contains("from -90deg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_90_matches_preview_for_all_origins_and_directions() {
|
||||
let cases = [
|
||||
(Radial90Origin::TopLeft, 0.0),
|
||||
(Radial90Origin::TopRight, 90.0),
|
||||
(Radial90Origin::BottomRight, 180.0),
|
||||
(Radial90Origin::BottomLeft, 270.0),
|
||||
];
|
||||
for (origin, angle) in cases {
|
||||
let clockwise = fill_clip_path(
|
||||
&FillMethod::Radial90 {
|
||||
origin,
|
||||
clockwise: true,
|
||||
},
|
||||
0.5,
|
||||
)
|
||||
.unwrap();
|
||||
let counter_clockwise = fill_clip_path(
|
||||
&FillMethod::Radial90 {
|
||||
origin,
|
||||
clockwise: false,
|
||||
},
|
||||
0.5,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(clockwise.contains(&format!("from {}deg", angle - 90.0)));
|
||||
assert!(counter_clockwise.contains(&format!("from {}deg", angle - 45.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn conic_mask(origin: f32, clockwise: bool, amount: f32, max_sweep: f32) -> String {
|
||||
let sweep = amount * max_sweep;
|
||||
let start = if clockwise { origin } else { origin - sweep };
|
||||
format!(
|
||||
"conic-gradient(from {}deg, #000 0deg {}deg, transparent {}deg 360deg)",
|
||||
trim_float(start),
|
||||
trim_float(sweep),
|
||||
trim_float(sweep)
|
||||
)
|
||||
}
|
||||
fn radial_origin_angle_90(origin: Radial90Origin) -> f32 {
|
||||
match origin {
|
||||
Radial90Origin::TopLeft => 0.0,
|
||||
Radial90Origin::TopRight => 90.0,
|
||||
Radial90Origin::BottomRight => 180.0,
|
||||
Radial90Origin::BottomLeft => 270.0,
|
||||
}
|
||||
}
|
||||
fn radial_origin_angle_180(origin: Radial180Origin) -> f32 {
|
||||
match origin {
|
||||
Radial180Origin::Top => 0.0,
|
||||
Radial180Origin::Right => 90.0,
|
||||
Radial180Origin::Bottom => 180.0,
|
||||
Radial180Origin::Left => 270.0,
|
||||
}
|
||||
}
|
||||
fn radial_origin_angle_360(origin: Radial360Origin) -> f32 {
|
||||
match origin {
|
||||
Radial360Origin::Top => 0.0,
|
||||
Radial360Origin::Right => 90.0,
|
||||
Radial360Origin::Bottom => 180.0,
|
||||
Radial360Origin::Left => 270.0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use super::assets::{asset_url, hex_id};
|
||||
use crate::ui_editor::component::text::FontSource;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::state::State;
|
||||
use image::image_styles;
|
||||
use maud::{html, Markup};
|
||||
use text::text_style;
|
||||
|
||||
mod image;
|
||||
mod text;
|
||||
|
||||
pub(super) fn render_component(state: &State, component: &Component) -> Result<Markup, String> {
|
||||
match component {
|
||||
Component::Text(text) => {
|
||||
let mut style = text_style(
|
||||
text.font_style,
|
||||
text.alignment,
|
||||
text.font_sizing,
|
||||
text.color,
|
||||
text.line_spacing.get(),
|
||||
text.horizontal_overflow,
|
||||
text.vertical_overflow,
|
||||
);
|
||||
if let FontSource::Bound(font_id) = &text.font {
|
||||
state
|
||||
.font_assets
|
||||
.get(font_id)
|
||||
.ok_or_else(|| format!("Text 缺少字体资源:{}", font_id.as_str()))?;
|
||||
let family = format!("ui-editor-font-{}", hex_id(font_id.as_str()));
|
||||
style.push_str(&format!("font-family:'{family}';"));
|
||||
return Ok(html! {
|
||||
div style=(style) {
|
||||
(text.content)
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(html! { div style=(style) { (text.content) } })
|
||||
}
|
||||
Component::Image(image) => {
|
||||
let sprite_id = image
|
||||
.target_graphic
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Image 缺少 target_graphic".to_string())?;
|
||||
let sprite = state
|
||||
.sprite_assets
|
||||
.get(sprite_id)
|
||||
.ok_or_else(|| format!("Image 缺少 Sprite 资源:{}", sprite_id.as_str()))?;
|
||||
let src = asset_url(&sprite.path)?;
|
||||
let (style, image_style) = image_styles(&image.image_type, sprite, &src)?;
|
||||
Ok(match image_style {
|
||||
Some(image_style) => html! {
|
||||
div style=(style) {
|
||||
img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style);
|
||||
}
|
||||
},
|
||||
None => html! { div style=(style) {} },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use crate::ui_editor::component::text::{
|
||||
FontSizing, FontStyle, HorizontalTextOverflow, TextAlignment, VerticalTextOverflow,
|
||||
};
|
||||
|
||||
const UI_SCALE: &str = "var(--ui-scale, 1)";
|
||||
|
||||
pub(in crate::ui_editor::html_renderer) fn text_style(
|
||||
font_style: FontStyle,
|
||||
alignment: TextAlignment,
|
||||
sizing: FontSizing,
|
||||
color: [u8; 4],
|
||||
line_spacing: f32,
|
||||
horizontal_overflow: HorizontalTextOverflow,
|
||||
vertical_overflow: VerticalTextOverflow,
|
||||
) -> String {
|
||||
let [r, g, b, a] = color;
|
||||
let (justify, align, text_align) = match alignment {
|
||||
TextAlignment::UpperLeft | TextAlignment::MiddleLeft | TextAlignment::LowerLeft => (
|
||||
"flex-start",
|
||||
if matches!(alignment, TextAlignment::MiddleLeft) {
|
||||
"center"
|
||||
} else if matches!(alignment, TextAlignment::LowerLeft) {
|
||||
"flex-end"
|
||||
} else {
|
||||
"flex-start"
|
||||
},
|
||||
"left",
|
||||
),
|
||||
TextAlignment::UpperCenter | TextAlignment::MiddleCenter | TextAlignment::LowerCenter => (
|
||||
"center",
|
||||
if matches!(alignment, TextAlignment::MiddleCenter) {
|
||||
"center"
|
||||
} else if matches!(alignment, TextAlignment::LowerCenter) {
|
||||
"flex-end"
|
||||
} else {
|
||||
"flex-start"
|
||||
},
|
||||
"center",
|
||||
),
|
||||
_ => (
|
||||
"flex-end",
|
||||
if matches!(alignment, TextAlignment::MiddleRight) {
|
||||
"center"
|
||||
} else if matches!(alignment, TextAlignment::LowerRight) {
|
||||
"flex-end"
|
||||
} else {
|
||||
"flex-start"
|
||||
},
|
||||
"right",
|
||||
),
|
||||
};
|
||||
let (weight, italic) = match font_style {
|
||||
FontStyle::Normal => (400, "normal"),
|
||||
FontStyle::Bold => (700, "normal"),
|
||||
FontStyle::Italic => (400, "italic"),
|
||||
FontStyle::BoldItalic => (700, "italic"),
|
||||
};
|
||||
let size = match sizing {
|
||||
FontSizing::Fixed(value) => value.get(),
|
||||
FontSizing::BestFit(range) => range.min().get(),
|
||||
};
|
||||
let (white_space, overflow, wrap) = match (horizontal_overflow, vertical_overflow) {
|
||||
(HorizontalTextOverflow::Wrap, VerticalTextOverflow::Truncate) => {
|
||||
("normal", "hidden", "anywhere")
|
||||
}
|
||||
(HorizontalTextOverflow::Wrap, VerticalTextOverflow::Overflow) => {
|
||||
("normal", "visible", "anywhere")
|
||||
}
|
||||
(HorizontalTextOverflow::Overflow, VerticalTextOverflow::Truncate) => {
|
||||
("nowrap", "hidden", "normal")
|
||||
}
|
||||
(HorizontalTextOverflow::Overflow, VerticalTextOverflow::Overflow) => {
|
||||
("nowrap", "visible", "normal")
|
||||
}
|
||||
};
|
||||
format!("position:absolute;inset:0;display:flex;width:100%;height:100%;box-sizing:border-box;padding:0;color:rgba({r},{g},{b},{});font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;font-size:calc({size}px * {UI_SCALE});font-weight:{weight};font-style:{italic};align-items:{align};justify-content:{justify};text-align:{text_align};line-height:{};white-space:{white_space};overflow:{overflow};overflow-wrap:{wrap};", f32::from(a) / 255.0, line_spacing)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use super::assets::trim_float;
|
||||
use crate::ui_editor::layout::control_layout::{Container, ControlLayout};
|
||||
|
||||
const UI_SCALE: &str = "var(--ui-scale, 1)";
|
||||
|
||||
pub(super) fn container_style(container: &Container) -> Result<String, String> {
|
||||
Ok(match container {
|
||||
Container::None => String::new(),
|
||||
Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?),
|
||||
Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?),
|
||||
Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation)?, scaled_px(*v_separation)?),
|
||||
Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left)?, scaled_px(*margin_top)?, scaled_px(*margin_right)?, scaled_px(*margin_bottom)?),
|
||||
Container::Center { .. } => "display:grid;place-items:center;min-width:0;min-height:0;".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn child_container_style(
|
||||
layout: &ControlLayout,
|
||||
parent: &Container,
|
||||
) -> Result<String, String> {
|
||||
let min_w = scaled_px(layout.custom_minimum_size.x)?;
|
||||
let min_h = scaled_px(layout.custom_minimum_size.y)?;
|
||||
Ok(match parent {
|
||||
Container::HBox { .. } => format!(
|
||||
"min-width:{min_w};min-height:{min_h};{}align-self:{};",
|
||||
flex_grow(
|
||||
layout.size_flags_horizontal,
|
||||
layout.size_flags_stretch_ratio
|
||||
),
|
||||
cross_axis(layout.size_flags_vertical)
|
||||
),
|
||||
Container::VBox { .. } => format!(
|
||||
"min-width:{min_w};min-height:{min_h};{}align-self:{};",
|
||||
flex_grow(layout.size_flags_vertical, layout.size_flags_stretch_ratio),
|
||||
cross_axis(layout.size_flags_horizontal)
|
||||
),
|
||||
Container::Margin { .. } => {
|
||||
"grid-area:1 / 1;align-self:stretch;justify-self:stretch;".to_string()
|
||||
}
|
||||
Container::Center { use_top_left } => {
|
||||
if *use_top_left {
|
||||
"justify-self:center;align-self:center;transform:translate(50%,50%);".to_string()
|
||||
} else {
|
||||
"justify-self:center;align-self:center;".to_string()
|
||||
}
|
||||
}
|
||||
Container::Grid { .. } | Container::None => {
|
||||
format!("min-width:{min_w};min-height:{min_h};")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn scaled_px(value: f32) -> Result<String, String> {
|
||||
if !value.is_finite() {
|
||||
return Err("layout px 必须是有限数值".to_string());
|
||||
}
|
||||
Ok(format!("calc({}px * {})", trim_float(value), UI_SCALE))
|
||||
}
|
||||
fn alignment_css(
|
||||
value: crate::ui_editor::layout::control_layout::ContainerAlignment,
|
||||
) -> &'static str {
|
||||
match value {
|
||||
crate::ui_editor::layout::control_layout::ContainerAlignment::Begin => "flex-start",
|
||||
crate::ui_editor::layout::control_layout::ContainerAlignment::Center => "center",
|
||||
crate::ui_editor::layout::control_layout::ContainerAlignment::End => "flex-end",
|
||||
}
|
||||
}
|
||||
fn cross_axis(value: u8) -> &'static str {
|
||||
if value & 1 != 0 {
|
||||
"stretch"
|
||||
} else if value == 4 {
|
||||
"center"
|
||||
} else if value == 8 {
|
||||
"flex-end"
|
||||
} else {
|
||||
"flex-start"
|
||||
}
|
||||
}
|
||||
fn flex_grow(flags: u8, ratio: f32) -> String {
|
||||
if flags & 2 != 0 && ratio.is_finite() && ratio >= 0.0 {
|
||||
format!("flex-grow:{};", trim_float(ratio))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
mod assets;
|
||||
mod component;
|
||||
mod container;
|
||||
mod node;
|
||||
|
||||
use self::assets::{font_face_rule, hex_id, html_comment, trim_float};
|
||||
use self::component::render_component;
|
||||
use self::container::{child_container_style, container_style};
|
||||
use self::node::{is_container, transform_style};
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::Container;
|
||||
use crate::ui_editor::layout::node::Node;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use maud::{html, Markup, PreEscaped};
|
||||
use serde_json::json;
|
||||
|
||||
pub(crate) fn render_ui_design_state_html(state: &State) -> Result<String, String> {
|
||||
let mut trees = Vec::with_capacity(state.ui_trees.len());
|
||||
for (index, tree) in state.ui_trees.iter().enumerate() {
|
||||
if index > 0 {
|
||||
trees.push("\n<!-- ui-tree-separator -->\n".to_string());
|
||||
}
|
||||
trees.push(render_tree(state, tree)?.into_string());
|
||||
}
|
||||
let font_faces = state
|
||||
.font_assets
|
||||
.values()
|
||||
.map(|font| {
|
||||
let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str()));
|
||||
font_face_rule(font, &family)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join("");
|
||||
let fragment_comment = html_comment(
|
||||
"genarrative-ui-fragment",
|
||||
json!({
|
||||
"format": "html-fragment",
|
||||
"treeCount": state.ui_trees.len(),
|
||||
}),
|
||||
);
|
||||
let fonts = (!font_faces.is_empty()).then(|| {
|
||||
html! {
|
||||
style data-ui-fonts { (PreEscaped(font_faces)) }
|
||||
}
|
||||
.into_string()
|
||||
});
|
||||
let mut fragment = String::new();
|
||||
fragment.push_str(&fragment_comment.into_string());
|
||||
if let Some(fonts) = fonts {
|
||||
fragment.push_str(&fonts);
|
||||
}
|
||||
fragment.push_str(&trees.concat());
|
||||
Ok(fragment)
|
||||
}
|
||||
|
||||
pub(crate) fn render_ui_design_state_js(
|
||||
state: &State,
|
||||
) -> Result<(String, Vec<String>, usize), String> {
|
||||
// 生成阶段只渲染已由保存流程 validate_state 校验过的 State;不重复执行领域校验。
|
||||
let font_faces = state
|
||||
.font_assets
|
||||
.values()
|
||||
.map(|font| {
|
||||
let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str()));
|
||||
font_face_rule(font, &family)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join("");
|
||||
let mut exports = Vec::with_capacity(state.ui_trees.len());
|
||||
let mut modules = Vec::with_capacity(state.ui_trees.len());
|
||||
let mut node_count = 0usize;
|
||||
for tree in &state.ui_trees {
|
||||
let export_name = format!(
|
||||
"{}_{}",
|
||||
tree_export_name(tree.src_ui_design.as_str()),
|
||||
hex_id(tree.root.id.as_str())
|
||||
);
|
||||
exports.push(export_name.clone());
|
||||
let image = state
|
||||
.ui_design_images
|
||||
.get(&tree.src_ui_design)
|
||||
.ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?;
|
||||
let tree_comment = html_comment(
|
||||
"genarrative-ui-tree",
|
||||
json!({
|
||||
"srcUiDesign": tree.src_ui_design.as_str(),
|
||||
"name": image.metadata.name,
|
||||
"description": image.metadata.description,
|
||||
}),
|
||||
)
|
||||
.into_string();
|
||||
let root = render_node_with_scale(
|
||||
state,
|
||||
&tree.root,
|
||||
None,
|
||||
Some((image.pixel_size.x, image.pixel_size.y)),
|
||||
)?
|
||||
.into_string();
|
||||
node_count += count_nodes(&tree.root);
|
||||
let mut fragment = String::new();
|
||||
fragment.push_str(&tree_comment);
|
||||
if !font_faces.is_empty() {
|
||||
fragment.push_str(
|
||||
&html! { style data-ui-fonts { (PreEscaped(font_faces.as_str())) } }.into_string(),
|
||||
);
|
||||
}
|
||||
fragment.push_str(&root);
|
||||
let escaped = escape_template_literal(&pretty_html_fragment(&fragment));
|
||||
modules.push(format!("export const {export_name} = `\n{escaped}\n`;"));
|
||||
}
|
||||
let mut output = String::from(
|
||||
r#"
|
||||
//这是从用户手动调整过的UI设计文档生成的html片段, 需要在合适的位置注入游戏中.
|
||||
// 注入示例:
|
||||
// import { tree_example } from '/ui/generated-xxx.js'; // 统一使用绝对路径这里
|
||||
// document.body.insertAdjacentHTML('beforeend', tree_example);
|
||||
// 生成规则默认根节点(们)占据整个窗口.
|
||||
//
|
||||
// 禁止直接修改本js模块因为它随时会被用户重新导出覆盖
|
||||
// 应用逻辑/修改方式, 使用稳定的ui-node-id来查找某个元素然后使用js修改.
|
||||
// const node = document.querySelector('[ui-node-id="..."]');
|
||||
//
|
||||
// html片段内注释包含了丰富的元数据需要你理解并且尽可能实现它们
|
||||
// 对于交互逻辑, 页面关系, 有不清楚的地方可以向用户询问
|
||||
// 对于动态内容, 此文档给出的可能只是一个(或几个在列表中)例子, 需要你根据游戏逻辑, 在js中动态实现.
|
||||
// 对于容器式布局, 文档可能错误地使用position来描述, 需要你合理地使用flex, grid, scrollable, overflow等重写.
|
||||
//
|
||||
// 必要时鼓励清理冗余的示例性的元素, 原则: 尽量保留复用原来的父级面板, patch其中的元素, 而不是替换整个面板
|
||||
"#,
|
||||
);
|
||||
|
||||
output.push_str(&modules.join("\n\n"));
|
||||
if !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
Ok((output, exports, node_count))
|
||||
}
|
||||
|
||||
fn render_tree(state: &State, tree: &UITree) -> Result<Markup, String> {
|
||||
let image = state
|
||||
.ui_design_images
|
||||
.get(&tree.src_ui_design)
|
||||
.ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?;
|
||||
let tree_comment = html_comment(
|
||||
"genarrative-ui-tree",
|
||||
json!({
|
||||
"srcUiDesign": tree.src_ui_design.as_str(),
|
||||
}),
|
||||
);
|
||||
let style = "position:relative;width:100%;height:100%;min-height:0;";
|
||||
Ok(html! {
|
||||
(tree_comment)
|
||||
div style=(style) { (render_node(state, &tree.root, None)?) }
|
||||
})
|
||||
}
|
||||
|
||||
fn render_node(
|
||||
state: &State,
|
||||
node: &Node,
|
||||
parent_container: Option<&Container>,
|
||||
) -> Result<Markup, String> {
|
||||
render_node_with_scale(state, node, parent_container, None)
|
||||
}
|
||||
|
||||
fn render_node_with_scale(
|
||||
state: &State,
|
||||
node: &Node,
|
||||
parent_container: Option<&Container>,
|
||||
root_scale: Option<(f32, f32)>,
|
||||
) -> Result<Markup, String> {
|
||||
let mut style = transform_style(&node.layout, parent_container.is_some())?;
|
||||
if let Some((width, height)) = root_scale {
|
||||
if !width.is_finite() || width <= 0.0 || !height.is_finite() || height <= 0.0 {
|
||||
return Err("UI 设计尺寸必须为正有限数值".to_string());
|
||||
}
|
||||
style.push_str(&format!(
|
||||
"--ui-scale:min(calc(100vw / {}px),calc(100vh / {}px));",
|
||||
trim_float(width),
|
||||
trim_float(height)
|
||||
));
|
||||
}
|
||||
style.push_str("border:0;outline:0;background:transparent;overflow:visible;");
|
||||
style.push_str(&container_style(&node.layout.container)?);
|
||||
if let Some(parent) = parent_container {
|
||||
style.push_str(&child_container_style(&node.layout, parent)?);
|
||||
}
|
||||
let comment = html_comment(
|
||||
"genarrative-ui-node",
|
||||
json!({
|
||||
"nodeId": node.id.as_str(),
|
||||
"name": node.metadata.name,
|
||||
"description": node.metadata.description,
|
||||
}),
|
||||
);
|
||||
let exclusive_comment = matches!(node.children_display_mode, ChildrenDisplayMode::Exclusive)
|
||||
.then(|| {
|
||||
html_comment(
|
||||
"genarrative-ui-node-group",
|
||||
json!({"childrenDisplayMode": "Exclusive", "childrenRendered": "all"}),
|
||||
)
|
||||
});
|
||||
let components = node
|
||||
.components
|
||||
.iter()
|
||||
.map(|component| render_component(state, component))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.map(|fragment| fragment.into_string())
|
||||
.collect::<Vec<_>>();
|
||||
let children = node
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| {
|
||||
render_node_with_scale(
|
||||
state,
|
||||
child,
|
||||
is_container(&node.layout.container).then_some(&node.layout.container),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.map(|fragment| fragment.into_string())
|
||||
.collect::<Vec<_>>();
|
||||
Ok(html! {
|
||||
(comment)
|
||||
@if let Some(group_comment) = exclusive_comment { (group_comment) }
|
||||
div ui-node-id=(node.id.as_str()) style=(style) {
|
||||
(PreEscaped(components.concat()))
|
||||
(PreEscaped(children.concat()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn count_nodes(node: &Node) -> usize {
|
||||
1 + node.children.iter().map(count_nodes).sum::<usize>()
|
||||
}
|
||||
|
||||
fn tree_export_name(id: &str) -> String {
|
||||
let mut name = String::from("tree_");
|
||||
for character in id.chars() {
|
||||
if character.is_ascii_alphanumeric() || character == '_' {
|
||||
name.push(character);
|
||||
} else {
|
||||
name.push('_');
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn escape_template_literal(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace('`', "\\`")
|
||||
.replace("${", "\\${")
|
||||
}
|
||||
|
||||
fn pretty_html_fragment(value: &str) -> String {
|
||||
let mut depth = 0usize;
|
||||
let mut lines = Vec::new();
|
||||
for raw in value.replace("><", ">\n<").lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let closing = line.starts_with("</");
|
||||
if closing {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
lines.push(format!("{}{}", " ".repeat(depth), line));
|
||||
if line.starts_with('<')
|
||||
&& !closing
|
||||
&& !line.starts_with("<!--")
|
||||
&& !line.starts_with("<!")
|
||||
&& !line.ends_with("/>")
|
||||
&& !line.starts_with("<img")
|
||||
&& !line.starts_with("<input")
|
||||
&& !line.starts_with("<br")
|
||||
&& !line.starts_with("<hr")
|
||||
&& !line.contains("</")
|
||||
{
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use super::assets::{finite, trim_float};
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
|
||||
const UI_SCALE: &str = "var(--ui-scale, 1)";
|
||||
|
||||
pub(super) fn transform_style(
|
||||
layout: &ControlLayout,
|
||||
in_container: bool,
|
||||
) -> Result<String, String> {
|
||||
let transform = &layout.transform;
|
||||
for value in transform
|
||||
.anchor_min
|
||||
.iter()
|
||||
.chain(transform.anchor_max.iter())
|
||||
.chain(transform.offset_min.iter())
|
||||
.chain(transform.offset_max.iter())
|
||||
{
|
||||
if !value.is_finite() {
|
||||
return Err("Transform 包含非有限数值".to_string());
|
||||
}
|
||||
}
|
||||
if transform.anchor_min.x > transform.anchor_max.x
|
||||
|| transform.anchor_min.y > transform.anchor_max.y
|
||||
{
|
||||
return Err("Transform anchors must be ordered".to_string());
|
||||
}
|
||||
if in_container {
|
||||
return Ok(format!(
|
||||
"position:relative;min-width:{};min-height:{};",
|
||||
scaled_px(layout.custom_minimum_size.x)?,
|
||||
scaled_px(layout.custom_minimum_size.y)?
|
||||
));
|
||||
}
|
||||
Ok(format!(
|
||||
"position:absolute;left:{};top:{};right:{};bottom:{};",
|
||||
css_length(transform.anchor_min.x * 100.0, transform.offset_min.x)?,
|
||||
css_length(transform.anchor_min.y * 100.0, transform.offset_min.y)?,
|
||||
css_length(
|
||||
(1.0 - transform.anchor_max.x) * 100.0,
|
||||
-transform.offset_max.x
|
||||
)?,
|
||||
css_length(
|
||||
(1.0 - transform.anchor_max.y) * 100.0,
|
||||
-transform.offset_max.y
|
||||
)?
|
||||
))
|
||||
}
|
||||
|
||||
fn css_length(percent: f32, offset: f32) -> Result<String, String> {
|
||||
if !percent.is_finite() || !offset.is_finite() {
|
||||
return Err("Transform 无法转换为 CSS".to_string());
|
||||
}
|
||||
if percent == 0.0 {
|
||||
return Ok(format!("calc({}px * {})", trim_float(offset), UI_SCALE));
|
||||
}
|
||||
if offset == 0.0 {
|
||||
return Ok(format!("{}%", trim_float(percent)));
|
||||
}
|
||||
Ok(format!(
|
||||
"calc({}% {} {}px * {})",
|
||||
trim_float(percent),
|
||||
if offset < 0.0 { '-' } else { '+' },
|
||||
trim_float(offset.abs()),
|
||||
UI_SCALE
|
||||
))
|
||||
}
|
||||
|
||||
fn scaled_px(value: f32) -> Result<String, String> {
|
||||
finite(value, "layout px")?;
|
||||
Ok(format!("calc({}px * {})", trim_float(value), UI_SCALE))
|
||||
}
|
||||
|
||||
pub(super) fn is_container(
|
||||
container: &crate::ui_editor::layout::control_layout::Container,
|
||||
) -> bool {
|
||||
!matches!(
|
||||
container,
|
||||
crate::ui_editor::layout::control_layout::Container::None
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod component;
|
||||
pub(crate) mod html_renderer;
|
||||
pub mod layout;
|
||||
pub mod persistence;
|
||||
pub mod resource;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::ui_editor::component::text::FontSource;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::html_renderer::render_ui_design_state_js;
|
||||
use crate::ui_editor::layout::node::Node;
|
||||
use crate::ui_editor::resource::ui_design_image::{
|
||||
UIDesignImage, UIDesignImageMetadata, UIDesignImageRole,
|
||||
@@ -9,6 +10,7 @@ use crate::ui_editor::utils::UIDesignImageId;
|
||||
use crate::*;
|
||||
use nalgebra::Vector2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
@@ -18,6 +20,7 @@ use typed_floats::tf32::StrictlyPositiveFinite;
|
||||
|
||||
const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1";
|
||||
const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024;
|
||||
const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8;
|
||||
const UI_DESIGN_STATE_MAX_IMAGES: usize = 4;
|
||||
const UI_DESIGN_STATE_MAX_SPRITES: usize = 1_024;
|
||||
pub(crate) const UI_DESIGN_STATE_MAX_NODES: usize = 10_000;
|
||||
@@ -43,6 +46,23 @@ pub(crate) struct LoadUiDesignStateInput {
|
||||
pub(crate) asset_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateUiDesignCodeInput {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) expected_project_id: String,
|
||||
pub(crate) asset_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateUiDesignCodeResult {
|
||||
pub(crate) relative_path: String,
|
||||
pub(crate) tree_exports: Vec<String>,
|
||||
pub(crate) tree_count: usize,
|
||||
pub(crate) node_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct SaveUiDesignStateInput {
|
||||
@@ -149,6 +169,52 @@ pub(crate) fn load_ui_design_state_at(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn generate_ui_design_code_at(
|
||||
input: GenerateUiDesignCodeInput,
|
||||
) -> Result<GenerateUiDesignCodeResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||
let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?;
|
||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
let document =
|
||||
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
||||
let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?;
|
||||
let relative_path = format!("ui/generated-{}.js", generated_file_stem(&asset_id));
|
||||
let path = resolve_local_project_path(root, &relative_path)?;
|
||||
write_ui_design_raw_file_with_limit(
|
||||
&path,
|
||||
"UI 设计生成代码",
|
||||
content.as_bytes(),
|
||||
UI_DESIGN_CODE_MAX_BYTES,
|
||||
)?;
|
||||
Ok(GenerateUiDesignCodeResult {
|
||||
relative_path,
|
||||
tree_count: tree_exports.len(),
|
||||
tree_exports,
|
||||
node_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn generated_file_stem(asset_id: &str) -> String {
|
||||
let mut stem = String::new();
|
||||
for character in asset_id.chars() {
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
|
||||
stem.push(character);
|
||||
} else {
|
||||
stem.push('_');
|
||||
}
|
||||
}
|
||||
let readable_stem = if stem.is_empty() {
|
||||
"ui-design"
|
||||
} else {
|
||||
stem.as_str()
|
||||
};
|
||||
// 保留可读前缀,并追加摘要以避免不同 ID 映射到同一路径。
|
||||
let digest = format!("{:x}", Sha256::digest(asset_id.as_bytes()));
|
||||
format!("{readable_stem}-{}", &digest[..16])
|
||||
}
|
||||
|
||||
pub(crate) fn save_ui_design_state_at(
|
||||
input: SaveUiDesignStateInput,
|
||||
) -> Result<SaveUiDesignStateResult, String> {
|
||||
@@ -382,8 +448,17 @@ fn serialize_ui_design_document(document: &PersistedUiDesignState) -> Result<Vec
|
||||
/// runtime sidecar protocol: a stable `.previous` is recoverable after a
|
||||
/// process crash, unlike a random `.replace.<pid>.<nonce>` file.
|
||||
fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if bytes.len() > UI_DESIGN_STATE_MAX_BYTES {
|
||||
return Err(format!("{label} 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"));
|
||||
write_ui_design_raw_file_with_limit(path, label, bytes, UI_DESIGN_STATE_MAX_BYTES)
|
||||
}
|
||||
|
||||
fn write_ui_design_raw_file_with_limit(
|
||||
path: &Path,
|
||||
label: &str,
|
||||
bytes: &[u8],
|
||||
max_bytes: usize,
|
||||
) -> Result<(), String> {
|
||||
if bytes.len() > max_bytes {
|
||||
return Err(format!("{label} 超过 {max_bytes} 字节上限"));
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?;
|
||||
fs::create_dir_all(parent).map_err(|error| format!("创建 {label} 目录失败:{error}"))?;
|
||||
@@ -920,6 +995,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_file_stem_keeps_distinct_asset_ids_distinct() {
|
||||
let first = generated_file_stem("a.b");
|
||||
let second = generated_file_stem("a/b");
|
||||
assert_ne!(first, second);
|
||||
assert!(first.starts_with("a_b-"));
|
||||
assert_eq!(first.len(), "a_b-".len() + 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_sprite_asset_when_saving_a_dragged_node_transform() {
|
||||
let (directory, asset_id) = fixture();
|
||||
@@ -959,6 +1043,34 @@ mod tests {
|
||||
assert_eq!(loaded.state, state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_ordered_out_of_range_transform_anchors() {
|
||||
let (directory, asset_id) = fixture();
|
||||
let mut state = state_with_sprite_and_dragged_transform();
|
||||
let transform = &mut state.ui_trees[0].root.children[0].layout.transform;
|
||||
transform.anchor_min = Vector2::new(-0.25, -0.5);
|
||||
transform.anchor_max = Vector2::new(1.5, 1.25);
|
||||
|
||||
let saved = save_ui_design_state_at(input(directory.path(), &asset_id, 0, state.clone()))
|
||||
.expect("save ordered out-of-range anchors");
|
||||
assert!(matches!(
|
||||
saved,
|
||||
SaveUiDesignStateResult::Saved {
|
||||
revision: 1,
|
||||
state: ref installed,
|
||||
..
|
||||
} if installed == &state
|
||||
));
|
||||
|
||||
let loaded = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||
project_path: directory.path().to_string_lossy().into_owned(),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
asset_id,
|
||||
})
|
||||
.expect("load ordered out-of-range anchors");
|
||||
assert_eq!(loaded.state, state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_inverted_transform_anchors() {
|
||||
let state: State = serde_json::from_value(serde_json::json!({
|
||||
|
||||
@@ -30,6 +30,15 @@ impl FontFormat {
|
||||
Self::Woff2 => "woff2",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn css_format(self) -> &'static str {
|
||||
match self {
|
||||
Self::TrueType => "truetype",
|
||||
Self::OpenType => "opentype",
|
||||
Self::Woff => "woff",
|
||||
Self::Woff2 => "woff2",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
@@ -210,4 +219,12 @@ mod tests {
|
||||
.expect_err("reject unparseable web font")
|
||||
.contains("无法安全解析"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_format_uses_css_format_strings() {
|
||||
assert_eq!(FontFormat::TrueType.css_format(), "truetype");
|
||||
assert_eq!(FontFormat::OpenType.css_format(), "opentype");
|
||||
assert_eq!(FontFormat::Woff.css_format(), "woff");
|
||||
assert_eq!(FontFormat::Woff2.css_format(), "woff2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -49,6 +50,36 @@ type ClientRuntimeErrorBoundaryState = {
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
type AuthCheckStage = 'token' | 'refresh' | 'me' | 'runner';
|
||||
|
||||
const AUTH_CHECK_STAGE_LABELS: Record<AuthCheckStage, string> = {
|
||||
token: '读取本地登录凭据',
|
||||
refresh: '刷新登录状态',
|
||||
me: '确认当前用户',
|
||||
runner: '连接本地运行时',
|
||||
};
|
||||
|
||||
const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000;
|
||||
// Existing endpoint probes can consume the 10s IPC budget before Runner's
|
||||
// 30s startup deadline. Keep the UI fence slightly above that worst case.
|
||||
const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000;
|
||||
|
||||
function withAuthCheckTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
) {
|
||||
let timeoutId: number | undefined;
|
||||
const timeout = new Promise<T>((_, reject) => {
|
||||
timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export class ClientRuntimeErrorBoundary extends Component<
|
||||
ClientRuntimeErrorBoundaryProps,
|
||||
ClientRuntimeErrorBoundaryState
|
||||
@@ -101,6 +132,11 @@ export function AuthenticatedClient({
|
||||
'checking' | 'authenticated' | 'unauthenticated'
|
||||
>('checking');
|
||||
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
|
||||
const [authCheckStage, setAuthCheckStage] = useState<AuthCheckStage>('token');
|
||||
const [authCheckElapsedSeconds, setAuthCheckElapsedSeconds] = useState(0);
|
||||
const [authCheckError, setAuthCheckError] = useState('');
|
||||
const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0);
|
||||
const authCheckRunRef = useRef(0);
|
||||
const [loginMode, setLoginMode] = useState<'code' | 'password'>('code');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
@@ -147,100 +183,162 @@ export function AuthenticatedClient({
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
async function hydrateAuth() {
|
||||
const runId = ++authCheckRunRef.current;
|
||||
const isActiveRun = () => !disposed && authCheckRunRef.current === runId;
|
||||
const hydrationGeneration = currentPlatformSessionGeneration();
|
||||
const hydrationApiBaseUrl = getClientServerBaseUrl();
|
||||
setAuthCheckError('');
|
||||
setAuthCheckStage('token');
|
||||
try {
|
||||
if (!getStoredAuthAccessToken()) {
|
||||
const refreshed = await refreshPlatformSessionForGeneration(
|
||||
hydrationGeneration,
|
||||
hydrationApiBaseUrl,
|
||||
setAuthCheckStage('refresh');
|
||||
const refreshed = await withAuthCheckTimeout(
|
||||
refreshPlatformSessionForGeneration(
|
||||
hydrationGeneration,
|
||||
hydrationApiBaseUrl,
|
||||
),
|
||||
AUTH_CHECK_REQUEST_TIMEOUT_MS,
|
||||
'刷新登录状态超时,请检查服务器地址和网络后重试',
|
||||
);
|
||||
if (!refreshed) {
|
||||
if (isActiveRun()) {
|
||||
setAuthStatus('unauthenticated');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const user = await getCurrentClientAuthUser(hydrationApiBaseUrl);
|
||||
if (disposed) {
|
||||
if (!isActiveRun()) return;
|
||||
setAuthCheckStage('me');
|
||||
const user = await withAuthCheckTimeout(
|
||||
getCurrentClientAuthUser(hydrationApiBaseUrl),
|
||||
AUTH_CHECK_REQUEST_TIMEOUT_MS,
|
||||
'读取当前用户超时,请检查服务器地址和网络后重试',
|
||||
);
|
||||
if (!isActiveRun()) {
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
const committedGeneration = await commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
hydrationGeneration,
|
||||
hydrationApiBaseUrl,
|
||||
setAuthCheckStage('runner');
|
||||
const committedGeneration = await withAuthCheckTimeout(
|
||||
commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
hydrationGeneration,
|
||||
hydrationApiBaseUrl,
|
||||
),
|
||||
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
||||
'连接本地运行时超时,请重试或重启客户端',
|
||||
);
|
||||
if (committedGeneration === null) {
|
||||
return;
|
||||
}
|
||||
if (disposed) {
|
||||
if (!isActiveRun()) {
|
||||
return;
|
||||
}
|
||||
setAuthUser(user);
|
||||
setAuthCheckError('');
|
||||
setAuthStatus('authenticated');
|
||||
return;
|
||||
}
|
||||
clearStoredAuthAccessToken();
|
||||
setAuthStatus('unauthenticated');
|
||||
} catch (error) {
|
||||
if (disposed) {
|
||||
if (!isActiveRun()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
getStoredAuthAccessToken() &&
|
||||
isClientAuthRecoverableCheckError(error)
|
||||
) {
|
||||
setLoginStatus(
|
||||
getClientAuthErrorMessage(error, '登录服务暂时不可用,请稍后重试'),
|
||||
const message = getClientAuthErrorMessage(
|
||||
error,
|
||||
'登录服务暂时不可用,请稍后重试',
|
||||
);
|
||||
setAuthCheckError(message);
|
||||
setLoginStatus(message);
|
||||
setAuthStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
if (getStoredAuthAccessToken()) {
|
||||
try {
|
||||
const refreshed = await refreshPlatformSessionForGeneration(
|
||||
hydrationGeneration,
|
||||
hydrationApiBaseUrl,
|
||||
if (!isActiveRun()) return;
|
||||
setAuthCheckStage('refresh');
|
||||
const refreshed = await withAuthCheckTimeout(
|
||||
refreshPlatformSessionForGeneration(
|
||||
hydrationGeneration,
|
||||
hydrationApiBaseUrl,
|
||||
),
|
||||
AUTH_CHECK_REQUEST_TIMEOUT_MS,
|
||||
'刷新登录状态超时,请检查服务器地址和网络后重试',
|
||||
);
|
||||
if (!refreshed) {
|
||||
if (isActiveRun()) {
|
||||
setAuthStatus('unauthenticated');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const user = await getCurrentClientAuthUser(hydrationApiBaseUrl);
|
||||
if (disposed) {
|
||||
if (!isActiveRun()) return;
|
||||
setAuthCheckStage('me');
|
||||
const user = await withAuthCheckTimeout(
|
||||
getCurrentClientAuthUser(hydrationApiBaseUrl),
|
||||
AUTH_CHECK_REQUEST_TIMEOUT_MS,
|
||||
'读取当前用户超时,请检查服务器地址和网络后重试',
|
||||
);
|
||||
if (!isActiveRun()) {
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
const committedGeneration =
|
||||
await commitAuthenticatedPlatformSession(
|
||||
if (!isActiveRun()) return;
|
||||
setAuthCheckStage('runner');
|
||||
const committedGeneration = await withAuthCheckTimeout(
|
||||
commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
hydrationGeneration,
|
||||
hydrationApiBaseUrl,
|
||||
);
|
||||
),
|
||||
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
||||
'连接本地运行时超时,请重试或重启客户端',
|
||||
);
|
||||
if (committedGeneration === null) {
|
||||
return;
|
||||
}
|
||||
if (disposed) {
|
||||
if (!isActiveRun()) {
|
||||
return;
|
||||
}
|
||||
setAuthCheckError('');
|
||||
setAuthUser(user);
|
||||
setAuthStatus('authenticated');
|
||||
return;
|
||||
}
|
||||
} catch (retryError) {
|
||||
if (!isActiveRun()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
getStoredAuthAccessToken() &&
|
||||
isClientAuthRecoverableCheckError(retryError)
|
||||
) {
|
||||
setLoginStatus(
|
||||
getClientAuthErrorMessage(
|
||||
retryError,
|
||||
'登录服务暂时不可用,请稍后重试',
|
||||
),
|
||||
const message = getClientAuthErrorMessage(
|
||||
retryError,
|
||||
'登录服务暂时不可用,请稍后重试',
|
||||
);
|
||||
setAuthCheckError(message);
|
||||
setLoginStatus(message);
|
||||
setAuthStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isActiveRun()) {
|
||||
return;
|
||||
}
|
||||
if (isClientAuthRecoverableCheckError(error)) {
|
||||
const message = getClientAuthErrorMessage(
|
||||
error,
|
||||
'登录服务暂时不可用,请稍后重试',
|
||||
);
|
||||
setAuthCheckError(message);
|
||||
setLoginStatus(message);
|
||||
}
|
||||
clearStoredAuthAccessToken();
|
||||
setAuthStatus('unauthenticated');
|
||||
}
|
||||
@@ -249,7 +347,18 @@ export function AuthenticatedClient({
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
}, [authCheckRetryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus !== 'checking') {
|
||||
return;
|
||||
}
|
||||
setAuthCheckElapsedSeconds(0);
|
||||
const timer = window.setInterval(() => {
|
||||
setAuthCheckElapsedSeconds((current) => current + 1);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [authStatus, authCheckRetryKey]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@@ -358,6 +467,7 @@ export function AuthenticatedClient({
|
||||
return;
|
||||
}
|
||||
setAuthUser(user);
|
||||
setAuthCheckError('');
|
||||
setAuthStatus('authenticated');
|
||||
setCode('');
|
||||
setPassword('');
|
||||
@@ -383,6 +493,7 @@ export function AuthenticatedClient({
|
||||
nativeClearError = error;
|
||||
}
|
||||
setAuthUser(null);
|
||||
setAuthCheckError('');
|
||||
setAuthStatus('unauthenticated');
|
||||
setLoginStatus(
|
||||
nativeClearError
|
||||
@@ -392,6 +503,7 @@ export function AuthenticatedClient({
|
||||
}
|
||||
|
||||
if (authStatus === 'checking') {
|
||||
const stageLabel = AUTH_CHECK_STAGE_LABELS[authCheckStage];
|
||||
return (
|
||||
<main
|
||||
className="client-auth-shell platform-theme platform-theme--light"
|
||||
@@ -400,6 +512,14 @@ export function AuthenticatedClient({
|
||||
<section className="client-auth-panel">
|
||||
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
|
||||
<h1>正在检查登录状态</h1>
|
||||
<p className="client-auth-status" aria-live="polite">
|
||||
{stageLabel} · 已等待 {authCheckElapsedSeconds} 秒
|
||||
</p>
|
||||
<p className="client-auth-status" aria-live="polite">
|
||||
{authCheckElapsedSeconds >= 8
|
||||
? '检查时间较长,请确认服务器可访问;若持续无响应可稍后重试'
|
||||
: '正在恢复会话,请稍候'}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
@@ -417,6 +537,23 @@ export function AuthenticatedClient({
|
||||
<h1>登录陶泥儿 GameAgent</h1>
|
||||
<p>登录后进入首页和本地项目工作区</p>
|
||||
</div>
|
||||
{authCheckError ? (
|
||||
<div role="alert" className="client-auth-status">
|
||||
<p>{authCheckError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
beginPlatformSessionTransition();
|
||||
setAuthCheckError('');
|
||||
setAuthStatus('checking');
|
||||
setAuthCheckRetryKey((current) => current + 1);
|
||||
}}
|
||||
disabled={loginBusy || codeBusy}
|
||||
>
|
||||
重试登录状态检查
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<label>
|
||||
服务器
|
||||
<select
|
||||
@@ -523,7 +660,9 @@ export function AuthenticatedClient({
|
||||
<button type="submit" disabled={loginBusy}>
|
||||
{loginBusy ? '登录中' : '登录'}
|
||||
</button>
|
||||
<p className="client-auth-status">{loginStatus}</p>
|
||||
{!authCheckError ? (
|
||||
<p className="client-auth-status">{loginStatus}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -410,7 +410,11 @@ export function GddApprovalCard({
|
||||
const canDecide = Boolean(
|
||||
pending && state.state === 'ready_for_approval' && !state.recoveryPending,
|
||||
);
|
||||
const decisionDisabled = !canDecide || decisionBusy || hydrateBusy;
|
||||
// `hydrateBusy` only describes an in-flight background read. Once a card is rendered, the
|
||||
// loaded projection remains the actionable snapshot until it explicitly reports
|
||||
// `recoveryPending`; otherwise a refresh racing the first render can transiently disable an
|
||||
// already-open dialog before the user has had a chance to submit it.
|
||||
const decisionDisabled = !canDecide || decisionBusy;
|
||||
|
||||
const submitComment = () => {
|
||||
// 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮
|
||||
|
||||
@@ -14,6 +14,13 @@ export type UiDesignStateSaveResult =
|
||||
}
|
||||
| { status: 'conflict'; current: UiDesignStateSnapshot };
|
||||
|
||||
export type UiDesignCodeGenerationResult = {
|
||||
relativePath: string;
|
||||
treeExports: string[];
|
||||
treeCount: number;
|
||||
nodeCount: number;
|
||||
};
|
||||
|
||||
export type IUiDesignStateStore = {
|
||||
load(assetId: string): Promise<UiDesignStateSnapshot>;
|
||||
save(
|
||||
@@ -21,6 +28,7 @@ export type IUiDesignStateStore = {
|
||||
expectedRevision: number,
|
||||
state: State,
|
||||
): Promise<UiDesignStateSaveResult>;
|
||||
generateCode(assetId: string): Promise<UiDesignCodeGenerationResult>;
|
||||
};
|
||||
|
||||
export function createTauriUiDesignStateStore(
|
||||
@@ -44,6 +52,11 @@ export function createTauriUiDesignStateStore(
|
||||
},
|
||||
});
|
||||
},
|
||||
generateCode(assetId) {
|
||||
return invoke<UiDesignCodeGenerationResult>('generate_ui_design_code', {
|
||||
input: { projectPath, expectedProjectId, assetId },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +89,9 @@ export const uiDesignStateStore: IUiDesignStateStore = {
|
||||
committedProjectRevision: 0,
|
||||
};
|
||||
},
|
||||
async generateCode() {
|
||||
throw new Error('当前环境不支持生成代码');
|
||||
},
|
||||
};
|
||||
|
||||
export const EMPTY_UI_DESIGN_STATE = EMPTY_UI_EDITOR_STATE;
|
||||
|
||||
@@ -80,14 +80,22 @@ function fillClipPath(method: FillMethod, amount: number): string | undefined {
|
||||
const sweep = amount * maxSweep;
|
||||
const origin = config.origin;
|
||||
const originAngle =
|
||||
origin === 'Top' || origin === 'TopLeft' || origin === 'TopRight'
|
||||
? 0
|
||||
: origin === 'Right' || origin === 'BottomRight'
|
||||
? 90
|
||||
: origin === 'Bottom' || origin === 'BottomLeft'
|
||||
? 180
|
||||
: 270;
|
||||
const start = config.clockwise ? originAngle : originAngle - sweep;
|
||||
kind === 'Radial90'
|
||||
? origin === 'TopLeft'
|
||||
? 0
|
||||
: origin === 'TopRight'
|
||||
? 90
|
||||
: origin === 'BottomRight'
|
||||
? 180
|
||||
: 270
|
||||
: origin === 'Top'
|
||||
? 0
|
||||
: origin === 'Right'
|
||||
? 90
|
||||
: origin === 'Bottom'
|
||||
? 180
|
||||
: 270;
|
||||
const start = originAngle - (config.clockwise ? 90 : sweep);
|
||||
// CSS conic gradients provide a deterministic browser preview for radial
|
||||
// fills. Exact engine parity is intentionally deferred.
|
||||
return `conic-gradient(from ${start}deg, #000 0deg ${sweep}deg, transparent ${sweep}deg 360deg)`;
|
||||
@@ -141,6 +149,7 @@ function imageTypeModel(
|
||||
const size = logicalSize(sprite, multiplier);
|
||||
|
||||
if ('Tiled' in imageType) {
|
||||
// TODO: Tiled.fill_center=false 当前暂不支持,保持浏览器 repeat fallback。
|
||||
return {
|
||||
kind: 'background',
|
||||
src,
|
||||
|
||||
@@ -4,6 +4,32 @@ export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
|
||||
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
|
||||
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
|
||||
export const AGC_CLIENT_MARKER_VALUE = 'agc';
|
||||
/**
|
||||
* Upper bound for the initial network transaction (DNS/connect/response
|
||||
* headers). Callers may override this for a request that legitimately needs
|
||||
* more time; the default prevents auth/bootstrap requests from hanging
|
||||
* forever when the selected server or proxy is unavailable.
|
||||
*/
|
||||
export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class ClientHttpTimeoutError extends Error {
|
||||
readonly code = 'CLIENT_HTTP_TIMEOUT';
|
||||
readonly timeoutMs: number;
|
||||
readonly url: string;
|
||||
|
||||
constructor(url: string, timeoutMs: number) {
|
||||
super(`请求超时(${timeoutMs} ms):${url}`);
|
||||
this.name = 'ClientHttpTimeoutError';
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
export function isClientHttpTimeoutError(
|
||||
error: unknown,
|
||||
): error is ClientHttpTimeoutError {
|
||||
return error instanceof ClientHttpTimeoutError;
|
||||
}
|
||||
|
||||
export type ClientServerPreset = 'release' | 'dev' | 'custom';
|
||||
|
||||
@@ -171,7 +197,11 @@ export function resolveClientHttpTarget(
|
||||
export async function fetchClientHttp(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
options: { serverBaseUrl?: string } = {},
|
||||
options: {
|
||||
serverBaseUrl?: string;
|
||||
/** Set to null to opt out for a long-running request. */
|
||||
timeoutMs?: number | null;
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
const currentContext = currentClientHttpContext();
|
||||
const serverBaseUrl = options.serverBaseUrl
|
||||
@@ -186,8 +216,86 @@ export async function fetchClientHttp(
|
||||
: { ...currentContext, serverBaseUrl },
|
||||
);
|
||||
const markedInit = withAgcClientMarker(init);
|
||||
if (target.transport === 'tauri-http') {
|
||||
return tauriHttpFetch(target.url, markedInit);
|
||||
|
||||
// Always use a private controller so an internal timeout cannot mutate a
|
||||
// caller-owned AbortSignal. The caller's signal is still propagated in
|
||||
// both directions, preserving normal AbortError behaviour for user aborts.
|
||||
const timeoutMs =
|
||||
options.timeoutMs === undefined
|
||||
? CLIENT_HTTP_DEFAULT_TIMEOUT_MS
|
||||
: options.timeoutMs;
|
||||
if (timeoutMs === null) {
|
||||
if (target.transport === 'tauri-http') {
|
||||
return tauriHttpFetch(target.url, markedInit);
|
||||
}
|
||||
return fetch(target.url, markedInit);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new RangeError('请求超时时间必须是大于 0 的有限数值');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const callerSignal = markedInit.signal;
|
||||
const forwardCallerAbort = () => {
|
||||
// AbortSignal.reason is available in modern browsers/Tauri WebViews. The
|
||||
// fallback keeps compatibility with older runtimes and test doubles.
|
||||
const reason = callerSignal?.reason;
|
||||
try {
|
||||
controller.abort(reason);
|
||||
} catch {
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
if (callerSignal) {
|
||||
if (callerSignal.aborted) {
|
||||
forwardCallerAbort();
|
||||
} else {
|
||||
callerSignal.addEventListener('abort', forwardCallerAbort, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const requestInit = { ...markedInit, signal: controller.signal };
|
||||
let request: Promise<Response>;
|
||||
try {
|
||||
// Keep invocation synchronous so an already-aborted caller signal is
|
||||
// observed by transports that only subscribe to `abort` events.
|
||||
const responsePromise =
|
||||
target.transport === 'tauri-http'
|
||||
? tauriHttpFetch(target.url, requestInit)
|
||||
: fetch(target.url, requestInit);
|
||||
request = Promise.resolve(responsePromise);
|
||||
} catch (error) {
|
||||
callerSignal?.removeEventListener('abort', forwardCallerAbort);
|
||||
throw error;
|
||||
}
|
||||
// A timed-out request is intentionally not awaited after the race settles,
|
||||
// but transports may still reject when the abort reaches them. Attach a
|
||||
// sink to avoid an unhandled rejection while keeping the original promise
|
||||
// in the race for normal errors.
|
||||
void request.catch(() => undefined);
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
reject(new ClientHttpTimeoutError(target.url, timeoutMs));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([request, timeout]);
|
||||
} catch (error) {
|
||||
// Some transports reject with a generic error after AbortController.abort;
|
||||
// expose a stable, actionable error to auth/bootstrap callers.
|
||||
if (timedOut) {
|
||||
throw new ClientHttpTimeoutError(target.url, timeoutMs);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
|
||||
callerSignal?.removeEventListener('abort', forwardCallerAbort);
|
||||
}
|
||||
return fetch(target.url, markedInit);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user