Merge remote-tracking branch 'origin/master' into codex/fix-agc-command-runtime-ci

This commit is contained in:
2026-09-21 10:43:06 +00:00
58 changed files with 1347 additions and 205 deletions
@@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { inflateSync } from 'node:zlib';
export const appIdentifier = 'world.genarrative.ai-game-creator';
import { AGC_APP_IDENTIFIER } from './channel-identity.mjs';
// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。
export const appIdentifier = AGC_APP_IDENTIFIER;
export const configFileName = 'game-creator.config.json';
export const localConfigFileName = 'game-creator.config.local.json';
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
@@ -14,6 +14,7 @@ import {
resolveReleasePartition,
runTauriBuild,
} from './build-release.mjs';
import { resolveChannelInstallIdentity } from './channel-identity.mjs';
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
import {
readUpdaterPubkey,
@@ -39,27 +40,19 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = path.resolve(appRoot, '../..');
/**
* 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。
* 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。
* 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置):
* 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后
* 让入口静默找错对象(清理、打包、归档三处一起失效)。
*/
function readProductName() {
const read = (file) =>
JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8'));
const base = read('tauri.conf.json');
const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json');
const productName = fs.existsSync(macosPath)
? (read('tauri.macos.conf.json').productName ?? base.productName)
: base.productName;
function resolveProductName(channel) {
const { productName } = resolveChannelInstallIdentity(channel);
assert.ok(
typeof productName === 'string' && productName.trim().length > 0,
'Tauri 配置缺少 productName',
'渠道安装身份缺少 productName',
);
return productName;
}
const productName = readProductName();
const appBundleName = `${productName}.app`;
const updaterArtifactName = `${productName}.app.tar.gz`;
assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行');
assert.equal(
process.env.JENKINS_URL?.length > 0,
@@ -102,6 +95,9 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
const macTarget = 'aarch64-apple-darwin';
const context = resolveReleaseContext([`--target=${macTarget}`]);
const partition = resolveReleasePartition(context.channel, context.target);
const productName = resolveProductName(context.channel);
const appBundleName = `${productName}.app`;
const updaterArtifactName = `${productName}.app.tar.gz`;
const version = await prepareReleaseVersion(context);
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
@@ -13,6 +13,10 @@ import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import {
resolveChannelInstallIdentity,
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
@@ -89,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
const defaultOssBaseUrl =
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
const reservedChannelNames = new Set([
'win',
'mac',
'windows',
'macos',
'darwin',
'linux',
]);
export { resolveReleaseChannel } from './channel-identity.mjs';
/**
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
@@ -165,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) {
throw new Error(`不支持的发布目标:${target}`);
}
export function resolveReleaseChannel(env = process.env) {
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
if (
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
channel.endsWith('-') ||
reservedChannelNames.has(channel) ||
/-(win|mac)$/u.test(channel)
) {
throw new Error(
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
);
}
return channel;
}
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
export function resolveReleasePartition(
channel = resolveReleaseChannel(),
@@ -428,12 +410,19 @@ export function buildTauriBuildArguments(
];
}
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
/**
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
* 不同渠道必须在同一台设备上并存而不是互相顶掉。
*/
export function createChannelConfig(
channel = resolveReleaseChannel(),
target = defaultTarget(),
) {
const { productName, identifier } = resolveChannelInstallIdentity(channel);
return {
productName,
identifier,
plugins: {
updater: {
endpoints: [updateManifestUrl(channel, target)],
@@ -37,6 +37,11 @@ import {
selectReleaseArtifact,
updateManifestUrl,
} from './build-release.mjs';
import {
AGC_APP_IDENTIFIER,
AGC_PRODUCT_NAME,
resolveChannelInstallIdentity,
} from './channel-identity.mjs';
const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin';
@@ -184,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
);
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
productName: AGC_PRODUCT_NAME,
identifier: AGC_APP_IDENTIFIER,
plugins: {
updater: {
endpoints: [
@@ -204,6 +211,68 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
});
});
test('channel install identity isolates co-installed builds and keeps the default channel stable', () => {
// 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。
assert.deepEqual(resolveChannelInstallIdentity('dev'), {
productName: AGC_PRODUCT_NAME,
identifier: AGC_APP_IDENTIFIER,
});
assert.deepEqual(resolveChannelInstallIdentity('release'), {
productName: '陶泥儿 Release',
identifier: `${AGC_APP_IDENTIFIER}.release`,
});
assert.deepEqual(resolveChannelInstallIdentity('beta-2'), {
productName: '陶泥儿 Beta-2',
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
});
// 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。
for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) {
const identity = resolveChannelInstallIdentity(channel);
assert.notEqual(identity.productName, AGC_PRODUCT_NAME);
assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER);
assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`));
}
for (const channel of ['dev-win', 'Release', 'win', 'beta-']) {
assert.throws(
() => resolveChannelInstallIdentity(channel),
/发布渠道无效/u,
);
}
});
test('channel install identity is baked into the same build-time config as the endpoint', () => {
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
const config = createChannelConfig('release', windowsTarget);
assert.equal(config.productName, '陶泥儿 Release');
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
assert.match(
config.plugins.updater.endpoints[0],
/\/release-win\/latest\.json$/u,
);
});
});
test('channel products keep first-install selection working under the channel product name', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-'));
try {
const { productName } = resolveChannelInstallIdentity('release');
const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`);
writeFileSync(dmg, 'channel first installation disk image');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
assert.equal(
selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], {
target: 'aarch64-apple-darwin',
version: packageVersion,
}),
dmg,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('packaged renderer receives the same channel as the updater manifest', () => {
const context = resolveReleaseContext([], {
AGC_BUILD_TARGET: windowsTarget,
@@ -0,0 +1,73 @@
/**
* AGC 渠道 → 安装身份。
*
* 渠道同时决定两件事:
* - 更新端点:OSS 分区 `<channel>-win` / `<channel>-mac` 的清单地址;
* - 安装身份:`productName` 与 `identifier`。
*
* 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、
* Windows WebView2 数据目录以及 `%APPDATA%\<identifier>` 客户端数据目录。
* 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、
* 本地项目与运行锁。
*
* 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。
*/
export const AGC_DEFAULT_CHANNEL = 'dev';
export const AGC_PRODUCT_NAME = '陶泥儿';
export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator';
const reservedChannelNames = new Set([
'win',
'mac',
'windows',
'macos',
'darwin',
'linux',
]);
/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */
export function validateReleaseChannel(channel) {
if (
typeof channel !== 'string' ||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
channel.endsWith('-') ||
reservedChannelNames.has(channel) ||
/-(win|mac)$/u.test(channel)
) {
throw new Error(
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
);
}
return channel;
}
export function resolveReleaseChannel(env = process.env) {
return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev');
}
/** 安装身份里的展示后缀:`release` → `Release``beta-2` → `Beta-2`。 */
export function channelDisplaySuffix(channel) {
return validateReleaseChannel(channel)
.split('-')
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join('-');
}
/**
* 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀,
* 保证同一台设备上不同渠道互不覆盖。
*/
export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
validateReleaseChannel(channel);
if (channel === AGC_DEFAULT_CHANNEL) {
return Object.freeze({
productName: AGC_PRODUCT_NAME,
identifier: AGC_APP_IDENTIFIER,
});
}
return Object.freeze({
productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
identifier: `${AGC_APP_IDENTIFIER}.${channel}`,
});
}
@@ -27,6 +27,11 @@ import {
appIdentifier,
defaultRealSwarmTestTask,
} from './agent-swarm-test-chat.mjs';
import {
AGC_APP_IDENTIFIER,
AGC_PRODUCT_NAME,
resolveChannelInstallIdentity,
} from './channel-identity.mjs';
import {
askHidden,
assertSafeGameCreatorConfigDestination,
@@ -1308,7 +1313,8 @@ if (
}
for (const requiredSource of [
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
"import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'",
'export const appIdentifier = AGC_APP_IDENTIFIER',
"'--swarm-chat'",
"'--autonomous-game-build'",
"'--preview-serve'",
@@ -1319,14 +1325,38 @@ for (const requiredSource of [
}
}
if (tauriConfig.productName !== '陶泥儿') {
// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端
// 的升级链路与既有安装目录都会断开。
const defaultChannelIdentity = resolveChannelInstallIdentity('dev');
if (tauriConfig.productName !== AGC_PRODUCT_NAME) {
throw new Error('AI game creator shell productName drifted');
}
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) {
throw new Error('AI game creator shell identifier drifted');
}
if (
tauriConfig.productName !== defaultChannelIdentity.productName ||
tauriConfig.identifier !== defaultChannelIdentity.identifier
) {
throw new Error(
'AI game creator shell baseline config must match the default channel identity',
);
}
// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。
for (const channel of ['release', 'beta-2']) {
const identity = resolveChannelInstallIdentity(channel);
if (
identity.productName === defaultChannelIdentity.productName ||
identity.identifier === defaultChannelIdentity.identifier ||
!identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)
) {
throw new Error(`channel install identity not isolated: ${channel}`);
}
}
const expectedBundledDesignAgentResources = {
'design-agent': 'design-agent',
...Object.fromEntries(
@@ -174,8 +174,16 @@ test('macOS release entry and smoke script derive product names from config and
new URL('./build-macos-ci.mjs', import.meta.url),
'utf8',
);
// 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。
assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名');
// 产品名决定 *.app、updater 归档与 DMG 卷名:它必须从渠道安装身份派生,
// 写死会在换渠道或改名后静默找错对象。
assert.ok(
entry.includes('resolveChannelInstallIdentity'),
'入口必须从渠道安装身份派生产品名',
);
assert.ok(
entry.includes('resolveProductName(context.channel)'),
'产品名必须按当前发布渠道解析',
);
assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名');
assert.ok(
entry.includes("const macTarget = 'aarch64-apple-darwin'"),
@@ -652,7 +652,7 @@ pub(crate) fn import_local_godot_project(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.create")?;
if discover_local_godot_project_root(root)?.is_none() {
return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string());
return Err("所选工作区未在根目录或一层子目录发现 project.godot".to_string());
}
let _lock = acquire_project_write_lock(root, "project.create")?;
import_local_godot_project_at(root, project_id.trim(), name.trim())
@@ -862,6 +862,48 @@ pub(crate) fn validate_game_creator_private_path_ancestors(
Ok(())
}
/// 客户端安装身份基线:`productName` / `identifier` 由构建期按渠道注入。
///
/// 默认渠道保持基线身份,其它渠道派生 `<基线>.<渠道>`,因此同一台设备上
/// 不同渠道各自拥有独立的安装目录与 AppData 数据目录。
#[cfg(windows)]
const GAME_CREATOR_APP_IDENTIFIER: &str = "world.genarrative.ai-game-creator";
#[cfg(windows)]
fn is_game_creator_packaged_app_data_leaf(name: &std::ffi::OsStr) -> bool {
let Some(name) = name.to_str() else {
return false;
};
let Some(remainder) = name.strip_prefix(GAME_CREATOR_APP_IDENTIFIER) else {
return false;
};
if remainder.is_empty() {
return true;
}
// 渠道名是小写字母开头的 32 位以内小写字母、数字与连字符。
remainder
.strip_prefix('.')
.is_some_and(|channel| !channel.is_empty() && channel.len() <= 32)
}
/// 路径是否位于 `<平台配置根>/<安装身份目录>` 之内。提权助手是独立进程,
/// 看不到父进程的配置目录覆盖,因此这里必须按目录名识别全部渠道身份。
#[cfg(windows)]
fn path_is_inside_game_creator_packaged_app_data(root: &Path, path: &Path) -> bool {
let root = normalize_windows_policy_path(root);
let path = normalize_windows_policy_path(path);
let Ok(relative) = path.strip_prefix(&root) else {
return false;
};
relative
.components()
.next()
.is_some_and(|component| match component {
std::path::Component::Normal(name) => is_game_creator_packaged_app_data_leaf(name),
_ => false,
})
}
/// Automatic ACL repair for managed paths is limited to objects AGC owns. A
/// separate, explicit user-selected scope below covers native picker/project
/// root results, including projects stored outside the current profile.
@@ -900,11 +942,8 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
// elevated helper runs in a fresh process, so the in-memory runtime
// config-dir override is unavailable there; recognize the packaged
// path from the user's profile as well.
let packaged_app_data = home
.join("AppData")
.join("Local")
.join("world.genarrative.ai-game-creator");
if starts_with_path(&packaged_app_data) {
if path_is_inside_game_creator_packaged_app_data(&home.join("AppData").join("Local"), &path)
{
return true;
}
}
@@ -914,7 +953,7 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
.map(PathBuf::from)
.filter(|candidate| candidate.is_absolute())
{
if starts_with_path(&root.join("world.genarrative.ai-game-creator")) {
if path_is_inside_game_creator_packaged_app_data(&root, &path) {
return true;
}
}
@@ -1096,10 +1135,9 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop
.filter(|candidate| candidate.is_absolute())
{
if is_builtin_root(home.join(".config").join("genarrative"))
|| is_builtin_root(
home.join("AppData")
.join("Local")
.join("world.genarrative.ai-game-creator"),
|| path_is_inside_game_creator_packaged_app_data(
&home.join("AppData").join("Local"),
&path,
)
{
return WindowsAclRepairScope::Managed;
@@ -1110,7 +1148,7 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop
.map(PathBuf::from)
.filter(|candidate| candidate.is_absolute())
{
if is_builtin_root(root.join("world.genarrative.ai-game-creator")) {
if path_is_inside_game_creator_packaged_app_data(&root, &path) {
return WindowsAclRepairScope::Managed;
}
}
@@ -5044,18 +5082,38 @@ mod private_path_elevation_policy_tests {
#[cfg(windows)]
#[test]
fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() {
fn packaged_appdata_paths_keep_managed_repair_scope_for_every_channel() {
let root = std::env::var_os("LOCALAPPDATA")
.or_else(|| std::env::var_os("APPDATA"))
.map(PathBuf::from)
.expect("local appdata");
let packaged = root.join("world.genarrative.ai-game-creator");
let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display()));
assert!(game_creator_private_path_allows_auto_elevation(&verbatim));
// 默认渠道是基线目录,其它渠道派生 `<基线>.<渠道>`;提权助手按目录名识别,
// 两种身份都必须落在 managed 赋权范围内。
for leaf in [
"world.genarrative.ai-game-creator",
"world.genarrative.ai-game-creator.release",
"world.genarrative.ai-game-creator.beta-2",
] {
let packaged = root.join(leaf).join("diagnostics");
let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display()));
assert!(
game_creator_private_path_allows_auto_elevation(&verbatim),
"{leaf}"
);
assert_eq!(
game_creator_runtime_config_repair_scope(&verbatim),
WindowsAclRepairScope::Managed,
"{leaf}"
);
}
// 相似前缀不是安装身份目录,不能落进 managed 赋权范围。
let foreign = root.join("world.genarrative.ai-game-creator-backup");
assert!(!game_creator_private_path_allows_auto_elevation(&foreign));
assert_eq!(
game_creator_runtime_config_repair_scope(&verbatim),
WindowsAclRepairScope::Managed
game_creator_runtime_config_repair_scope(&foreign),
WindowsAclRepairScope::UserSelected
);
}
@@ -2005,6 +2005,13 @@ fn show_startup_error_dialog(log_path: Option<&Path>) {
}
}
/// 客户端产品名跟随构建期渠道身份:默认渠道是「陶泥儿」,其它渠道带渠道后缀
/// (例如「陶泥儿 Release」)。同机并存的渠道客户端因此在窗口标题、任务栏与
/// Alt-Tab 里可区分;默认渠道结果不变。
pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String {
app.package_info().name.clone()
}
/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir`
/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时
/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。
@@ -2468,6 +2475,16 @@ fn main() {
setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized");
error
})?;
// 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只
// 由构建期身份决定,因此必须在这里按产品名覆盖。
match app.get_webview_window("client") {
Some(window) => {
if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) {
app_log!("startup.window-title.failed: {error}");
}
}
None => app_log!("startup.window-title.failed: 缺少 client 主窗口"),
}
spawn_project_snapshot_scheduler(app.handle().clone());
if let Err(error) = builtin_plugins::initialize(&config_dir) {
app_log!("startup.builtin-plugins.initialize.failed: {error}");
@@ -84,9 +84,15 @@ fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool {
metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata)
}
/// Godot 工程标记:`project.godot` 解析为普通文件即命中。
///
/// 2026-09-21 解除“必须是普通文件”的限制:符号链接、Windows reparse point 与
/// 硬链接一律跟随,不再因为 `project.godot` 本身是链接而拒绝整个工作区。判据只
/// 保留“解析后仍是文件”,目录或悬空链接仍然不算命中。
fn inspect_godot_project_marker(root: &Path) -> Result<bool, String> {
let project_file = root.join("project.godot");
let metadata = match fs::symlink_metadata(&project_file) {
// `fs::metadata` 跟随符号链接 / reparse point,因此链接指向的真实对象才是判据。
let metadata = match fs::metadata(&project_file) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
@@ -96,64 +102,12 @@ fn inspect_godot_project_marker(root: &Path) -> Result<bool, String> {
));
}
};
if godot_metadata_is_link(&metadata) {
return Err(format!(
"Godot 项目文件不能是符号链接或 reparse point{}",
project_file.display()
));
}
if !metadata.is_file() {
return Err(format!(
"Godot 项目文件必须是普通文件:{}",
"Godot 项目文件必须是文件:{}",
project_file.display()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if metadata.nlink() != 1 {
return Err(format!(
"Godot 项目文件不能是硬链接文件:{}",
project_file.display()
));
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
};
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
let file = fs::File::open(&project_file).map_err(|error| {
format!(
"打开 Godot 项目文件失败:{}: {error}",
project_file.display()
)
})?;
// SAFETY: the structure is plain data initialized by GetFileInformationByHandle.
let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
// SAFETY: file owns a live handle and information is a valid output pointer.
// 取不到句柄信息、目录与 reparse point 一律按拒绝处理,保持 fail-closed。
if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0
|| information.dwFileAttributes
& (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)
!= 0
{
return Err(format!(
"读取 Godot 项目文件 Windows 身份失败:{}",
std::io::Error::last_os_error()
));
}
if information.nNumberOfLinks != 1 {
return Err(format!(
"Godot 项目文件不能是硬链接文件:{}",
project_file.display()
));
}
}
Ok(true)
}
@@ -166,6 +120,62 @@ fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result<String, S
Ok(name.to_string())
}
/// 把模板自带的 Godot 工程显示名改成用户选择的项目名。
///
/// 只改 `[application]` 段里的 `config/name` 一行:Godot 用它当工程显示名与窗口标题,
/// 工程身份仍是 `project.godot` 所在目录,改显示名不影响工程发现或相对根。找不到该行
/// 时保持模板原样,不猜测插入位置;除这一行以外的字节逐字保留。
pub(crate) fn apply_godot_project_display_name(root: &Path, name: &str) -> Result<(), String> {
let path = root.join("project.godot");
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(format!(
"读取 Godot 工程配置失败:{}: {error}",
path.display()
));
}
};
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
let ends_with_newline = text.ends_with('\n');
let mut replaced = false;
let mut lines = Vec::new();
for line in text.lines() {
let trimmed = line.trim_start();
if !replaced {
if let Some(rest) = trimmed.strip_prefix("config/name") {
let rest = rest.trim_start();
if let Some(value) = rest.strip_prefix('=') {
let value = value.trim();
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
let indent = &line[..line.len() - trimmed.len()];
lines.push(format!(
"{indent}config/name=\"{}\"",
escape_godot_project_string(name)
));
replaced = true;
continue;
}
}
}
}
lines.push(line.to_string());
}
if !replaced {
return Ok(());
}
let mut updated = lines.join(newline);
if ends_with_newline {
updated.push_str(newline);
}
write_game_creator_private_file(&path, updated.as_bytes(), "Godot 工程配置")
}
fn escape_godot_project_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> {
let Some(value) = value else {
return Ok(());
@@ -346,13 +356,9 @@ pub(crate) fn discover_local_godot_project_root(
matches.push(validate_godot_project_child_name(&entry.file_name())?);
}
matches.sort();
if matches.len() > 1 {
return Err(format!(
"工作区一层子目录中发现多个 Godot 项目:{}",
matches.join("")
));
}
let Some(relative_root) = matches.pop() else {
// 2026-09-21 解除“必须唯一”的限制:一层子目录出现多个 Godot 工程时按名称排序
// 取第一个,结果确定且可复现,不再因为存在第二个工程而整体失败关闭。
let Some(relative_root) = matches.into_iter().next() else {
return Ok(None);
};
@@ -694,9 +700,8 @@ pub(crate) fn import_local_godot_project_at(
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
return Err("Godot 工作区目录不存在或不是普通文件夹".to_string());
}
let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| {
"所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()
})?;
let godot_project_root = discover_local_godot_project_root(root)?
.ok_or_else(|| "所选工作区未在根目录或一层子目录发现 project.godot".to_string())?;
if project_id.is_empty() {
return Err("项目 ID 不能为空".to_string());
}
@@ -133,17 +133,18 @@ fn root_godot_project_takes_priority_over_direct_child_projects() {
}
#[test]
fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() {
fn picks_the_first_direct_child_godot_project_in_name_order() {
let workspace = godot_import_test_path("multiple-children");
write_godot_project(&workspace.join("alpha"), "Alpha");
write_godot_project(&workspace.join("beta"), "Beta");
let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous")
.expect_err("multiple direct child Godot projects must fail");
let result = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous")
.expect("multiple direct child Godot projects must resolve deterministically");
assert!(error.contains("多个 Godot 项目"), "{error}");
assert!(!workspace.join(".agent").exists());
assert!(!workspace.join("alpha/.agent").exists());
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha"));
assert_manifest_godot_root(&workspace, "alpha");
assert!(workspace.join(".agent/manifest.json").is_file());
// 未选中的候选工程不写任何 AGC 元数据。
assert!(!workspace.join("beta/.agent").exists());
fs::remove_dir_all(workspace).ok();
}
@@ -187,23 +188,21 @@ fn calibrates_existing_manifest_to_the_discovered_godot_root() {
}
#[test]
fn ambiguous_layout_does_not_rewrite_an_existing_manifest() {
fn ambiguous_layout_calibrates_an_existing_manifest_deterministically() {
let workspace = godot_import_test_path("ambiguous-existing");
init_local_game_project_at(&workspace, "existing-project", "Existing Project")
.expect("initialize existing workspace");
write_godot_project(&workspace.join("game"), "Game");
write_godot_project(&workspace.join("other"), "Other");
let manifest_path = workspace.join(".agent/manifest.json");
let original = fs::read(&manifest_path).expect("read original manifest");
let error = import_local_godot_project_at(&workspace, "ignored", "Ignored")
.expect_err("ambiguous existing workspace must fail");
let result = import_local_godot_project_at(&workspace, "ignored", "Ignored")
.expect("ambiguous existing workspace must calibrate to one candidate");
assert!(error.contains("多个 Godot 项目"), "{error}");
assert_eq!(
fs::read(&manifest_path).expect("read unchanged manifest"),
original
);
assert_eq!(result.manifest.project_id, "existing-project");
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game"));
assert_manifest_godot_root(&workspace, "game");
assert!(!workspace.join("game/.agent").exists());
assert!(!workspace.join("other/.agent").exists());
fs::remove_dir_all(workspace).ok();
}
@@ -286,7 +285,7 @@ fn manifest_read_rejects_unsafe_persisted_godot_project_root() {
#[cfg(unix)]
#[test]
fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() {
fn accepts_symbolic_link_project_marker() {
use std::os::unix::fs::symlink;
let workspace = godot_import_test_path("linked-marker");
@@ -294,11 +293,51 @@ fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() {
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
symlink("real.godot", workspace.join("project.godot")).expect("link project marker");
let error = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect_err("linked project.godot must fail");
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect("linked project.godot must be accepted");
assert!(error.contains("符号链接"), "{error}");
assert!(!workspace.join(".agent").exists());
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
assert_manifest_godot_root(&workspace, ".");
fs::remove_dir_all(workspace).ok();
}
#[cfg(windows)]
#[test]
fn accepts_windows_hard_link_project_marker() {
let workspace = godot_import_test_path("windows-hard-link-marker");
fs::create_dir_all(&workspace).expect("create hard link marker workspace");
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
fs::hard_link(
workspace.join("real.godot"),
workspace.join("project.godot"),
)
.expect("hard link project marker");
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect("hard linked project.godot must be accepted");
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
assert_manifest_godot_root(&workspace, ".");
fs::remove_dir_all(workspace).ok();
}
#[cfg(windows)]
#[test]
fn accepts_windows_reparse_project_marker() {
let workspace = godot_import_test_path("windows-reparse-marker");
fs::create_dir_all(&workspace).expect("create reparse marker workspace");
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
if std::os::windows::fs::symlink_file("real.godot", workspace.join("project.godot")).is_err() {
// 未开启开发者模式的机器创建文件符号链接需要额外权限,跳过而不是误报通过。
fs::remove_dir_all(workspace).ok();
return;
}
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect("reparse point project.godot must be accepted");
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
assert_manifest_godot_root(&workspace, ".");
fs::remove_dir_all(workspace).ok();
}
@@ -344,7 +383,7 @@ fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() {
#[cfg(unix)]
#[test]
fn rejects_linked_marker_inside_a_regular_child_candidate() {
fn accepts_linked_marker_inside_a_regular_child_candidate() {
use std::os::unix::fs::symlink;
let workspace = godot_import_test_path("linked-child-marker");
@@ -353,11 +392,12 @@ fn rejects_linked_marker_inside_a_regular_child_candidate() {
fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker");
symlink("real.godot", godot_root.join("project.godot")).expect("link project marker");
let error = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect_err("linked marker in a regular child must fail");
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect("linked marker in a regular child must be accepted");
assert!(error.contains("符号链接"), "{error}");
assert!(!workspace.join(".agent").exists());
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game"));
assert_manifest_godot_root(&workspace, "game");
assert!(!godot_root.join(".agent").exists());
fs::remove_dir_all(workspace).ok();
}
@@ -397,6 +437,42 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() {
fs::remove_dir_all(root).ok();
}
#[test]
fn rewrites_only_the_godot_display_name_line() {
let root = godot_import_test_path("display-name");
fs::create_dir_all(&root).expect("create project root");
fs::write(
root.join("project.godot"),
"config_version=5\n\n[application]\n\nconfig/name=\"模板名\"\nrun/main_scene=\"res://scenes/main.tscn\"\n",
)
.expect("write project.godot");
apply_godot_project_display_name(&root, "我的\"平台跳跃\"").expect("rewrite display name");
assert_eq!(
fs::read_to_string(root.join("project.godot")).expect("read project.godot"),
"config_version=5\n\n[application]\n\nconfig/name=\"我的\\\"平台跳跃\\\"\"\nrun/main_scene=\"res://scenes/main.tscn\"\n"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn keeps_a_godot_project_without_a_display_name_line_untouched() {
let root = godot_import_test_path("no-display-name");
fs::create_dir_all(&root).expect("create project root");
let original =
"config_version=5\n\n[application]\n\nrun/main_scene=\"res://scenes/main.tscn\"\n";
fs::write(root.join("project.godot"), original).expect("write project.godot");
apply_godot_project_display_name(&root, "我的项目").expect("no display name is not an error");
assert_eq!(
fs::read_to_string(root.join("project.godot")).expect("read project.godot"),
original
);
fs::remove_dir_all(root).ok();
}
fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) {
let manifest_path = workspace.join(".agent/manifest.json");
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
@@ -990,6 +990,16 @@ pub(crate) fn create_project_from_installed_template_at(
&project_name,
);
}
// Godot 模板同样按工程文件识别:走既有 Godot 导入流程,写入
// `godotProjectRoot` 并按用户输入改写工程显示名,不生成 Web 占位入口。
if discover_local_godot_project_root(&project_root)?.is_some() {
apply_godot_project_display_name(&project_root, &project_name)?;
return import_local_godot_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
&project_name,
);
}
init_local_game_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
@@ -1492,6 +1502,49 @@ mod tests {
fs::remove_dir_all(&projects_root).ok();
}
#[test]
fn godot_template_creates_native_project_with_relative_root_and_display_name() {
let cache_root = tempfile::tempdir().expect("temp dir");
let projects_root = unique_projects_root();
let config_source = "config_version=5\n\n[application]\n\nconfig/name=\"Godot 模板\"\nrun/main_scene=\"res://scenes/main.tscn\"\n";
let archive = build_archive(&[
("project.godot", config_source.as_bytes()),
("scenes/main.tscn", b"[gd_scene format=3]\n"),
]);
let mut summary = sample_summary();
summary.id = "godot-fixture-template".to_string();
summary.runtime = "godot".to_string();
summary.entry = "project.godot".to_string();
summary.zip_size_bytes = archive.len() as u64;
summary.zip_sha256 = sha256_hex(&archive);
let record = install_template_archive(cache_root.path(), &summary, &archive)
.expect("install Godot template");
let result = create_project_from_installed_template_at(
&projects_root,
Path::new(&record.project_dir),
Some("我的平台跳跃"),
false,
)
.expect("create Godot project from template");
// Godot 模板走 Godot 导入:记录相对根,且不生成 Web 占位入口与并行目录。
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
assert_eq!(result.manifest.name, "我的平台跳跃");
let project_root = Path::new(&result.project_path);
assert!(!project_root.join("game").exists());
assert!(project_root.join("scenes/main.tscn").is_file());
let config =
fs::read_to_string(project_root.join("project.godot")).expect("read project.godot");
assert!(config.contains("config/name=\"我的平台跳跃\""), "{config}");
assert!(
config.contains("run/main_scene=\"res://scenes/main.tscn\""),
"只改显示名,其余行逐字保留:{config}"
);
assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists());
fs::remove_dir_all(&projects_root).ok();
}
#[test]
fn refuses_to_create_project_when_template_is_not_installed() {
let projects_root = tempfile::tempdir().expect("temp dir");
@@ -2532,42 +2532,45 @@ fn project_directory_status_reports_workspace_relative_godot_root() {
}
#[test]
fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() {
fn project_directory_status_resolves_ambiguous_direct_child_godot_projects() {
let root = unique_project_path();
for child in ["alpha", "beta"] {
// 目录枚举顺序不可信:故意倒序创建,证明选择按名称而不是按创建顺序。
for child in ["beta", "alpha"] {
let godot_root = root.join(child);
fs::create_dir_all(&godot_root).expect("create nested Godot project");
fs::write(godot_root.join("project.godot"), "[application]\n")
.expect("write nested project.godot");
}
let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect_err("ambiguous Godot workspace must fail inspection");
let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("multiple direct child Godot projects must resolve deterministically");
assert!(error.contains("多个 Godot 项目"), "{error}");
assert!(status.is_godot_project);
assert_eq!(status.godot_project_root.as_deref(), Some("alpha"));
fs::remove_dir_all(root).ok();
}
#[test]
fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() {
fn godot_import_command_resolves_child_ambiguity_and_releases_the_lock() {
let root = unique_project_path();
for child in ["alpha", "beta"] {
for child in ["beta", "alpha"] {
let godot_root = root.join(child);
fs::create_dir_all(&godot_root).expect("create nested Godot project");
fs::write(godot_root.join("project.godot"), "[application]\n")
.expect("write nested project.godot");
}
let error = import_local_godot_project(
let result = import_local_godot_project(
root.to_string_lossy().into_owned(),
"ambiguous".to_string(),
"Ambiguous".to_string(),
)
.expect_err("ambiguous Godot workspace must fail before locking");
.expect("ambiguous Godot workspace must import the first candidate deterministically");
assert!(error.contains("多个 Godot 项目"), "{error}");
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha"));
assert!(root.join(".agent/manifest.json").is_file());
assert!(!root.join("beta/.agent").exists());
assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists());
assert!(!root.join(".agent").exists());
fs::remove_dir_all(root).ok();
}
@@ -164,7 +164,7 @@ pub(crate) fn open_game_creator_workspace_window(
existing.close().map_err(|error| error.to_string())?;
}
tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path))
.title("陶泥儿")
.title(crate::game_creator_product_name(&app))
.decorations(false)
.inner_size(1180.0, 820.0)
.min_inner_size(760.0, 560.0)
@@ -183,7 +183,7 @@ pub(crate) fn open_game_creator_launcher_window(
existing.set_focus().map_err(|error| error.to_string())?;
} else {
tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url())
.title("陶泥儿")
.title(crate::game_creator_product_name(&app))
.decorations(false)
.inner_size(820.0, 640.0)
.min_inner_size(720.0, 520.0)
@@ -78,7 +78,12 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
};
type RuntimeSettingsSection =
'general' | 'workspace' | 'agents' | 'extensions' | 'advanced' | 'about';
| 'general'
| 'workspace'
| 'agents'
| 'extensions'
| 'advanced'
| 'about';
type RuntimeConfigToast = {
tone: 'success' | 'error';
@@ -1076,30 +1081,16 @@ export function RuntimeConfigDialog({
: '已启用'}
</span>
{plugin.enabled && plugin.hasRuntime ? (
<>
<button
type="button"
disabled={
agcPluginsBusy ||
plugin.status === 'invalid'
}
onClick={() => void toggleAgcPlugin(plugin)}
>
{plugin.status === 'running'
? '停止'
: '启动'}
</button>
<button
type="button"
disabled={
agcPluginsBusy ||
plugin.status !== 'running'
}
onClick={() => void reloadPlugin(plugin)}
>
</button>
</>
<button
type="button"
disabled={
agcPluginsBusy ||
plugin.status !== 'running'
}
onClick={() => void reloadPlugin(plugin)}
>
</button>
) : null}
{plugin.status === 'running'
? plugin.panels.map((panel) => (
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Godot 空白 2D 工程">
<defs><linearGradient id="bg" x2="1" y2="1"><stop stop-color="#1b2733"/><stop offset="1" stop-color="#0b1220"/></linearGradient></defs>
<rect width="960" height="540" fill="url(#bg)"/>
<circle cx="812" cy="132" r="204" fill="#478cbf" opacity=".14"/>
<rect x="672" y="232" width="184" height="128" rx="18" fill="none" stroke="#478cbf" stroke-width="4" opacity=".65"/>
<circle cx="728" cy="296" r="13" fill="#478cbf" opacity=".75"/>
<circle cx="800" cy="296" r="13" fill="#478cbf" opacity=".75"/>
<rect x="72" y="132" width="6" height="196" rx="3" fill="#478cbf"/>
<text x="104" y="197" fill="#fff" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="42" font-weight="700">Godot 空白 2D 工程</text>
<text x="108" y="253" fill="#c9dbed" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="22">Godot 4.7 · GDScript</text>
<text x="108" y="302" fill="#8ca8c5" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="18">1280×720 窗口 · GL Compatibility</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,18 @@
{
"id": "godot-empty-2d",
"title": "Godot 空白 2D 工程",
"summary": "Godot 4.7 原生二维空白工程:1280×720 窗口、GL Compatibility 渲染、单场景入口与占位说明节点已就绪。",
"tags": [
"空白",
"起步工程",
"2d",
"godot"
],
"runtime": "godot",
"engine": "godot",
"engineVersion": "4.7",
"templateVersion": "0.1.0",
"entry": "project.godot",
"coverWidth": 960,
"coverHeight": 540
}
@@ -0,0 +1,6 @@
# Godot 4+ 编辑器与导入缓存
.godot/
# 导出产物
export/
build/
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="24" fill="#1b2733"/>
<rect x="26" y="30" width="76" height="60" rx="10" fill="none" stroke="#478cbf" stroke-width="6"/>
<circle cx="48" cy="60" r="7" fill="#478cbf"/>
<circle cx="80" cy="60" r="7" fill="#478cbf"/>
<rect x="46" y="100" width="36" height="6" rx="3" fill="#478cbf"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

@@ -0,0 +1,16 @@
; Engine configuration file.
; AGC 模板库:Godot 4.7 空白 2D 工程。解压后即为工程根。
config_version=5
[application]
config/name="Godot 空白 2D 工程"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://icon.svg"
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
@@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
[node name="Main" type="Node2D"]
script = ExtResource("1_main")
[node name="Hint" type="Label" parent="."]
offset_left = 48.0
offset_top = 48.0
offset_right = 1232.0
offset_bottom = 160.0
text = "AGC · Godot 空白 2D 工程"
theme_override_font_sizes/font_size = 28
@@ -0,0 +1,12 @@
extends Node2D
## AGC · Godot 空白 2D 工程
##
## 只保留最小可运行骨架:一个 Node2D 根节点加一段说明文字。
## 继续开发时把新场景放进 `scenes/`,其余节点从 `scenes/main.tscn` 挂载。
@onready var _hint: Label = $Hint
func _ready() -> void:
_hint.text = "AGC · Godot 空白 2D 工程\n把场景挂到 scenes/ 即可开始,入口在 project.godot 的 run/main_scene"
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Godot 空白 3D 场景">
<defs><linearGradient id="bg" x2="1" y2="1"><stop stop-color="#16202c"/><stop offset="1" stop-color="#080d16"/></linearGradient></defs>
<rect width="960" height="540" fill="url(#bg)"/>
<circle cx="806" cy="150" r="200" fill="#478cbf" opacity=".12"/>
<path d="M764 262 828 300 828 376 764 414 700 376 700 300Z" fill="none" stroke="#478cbf" stroke-width="3" opacity=".7"/>
<path d="M700 300 764 338 828 300M764 338V414" fill="none" stroke="#478cbf" stroke-width="3" opacity=".45"/>
<rect x="72" y="132" width="6" height="196" rx="3" fill="#478cbf"/>
<text x="104" y="197" fill="#fff" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="42" font-weight="700">Godot 空白 3D 场景</text>
<text x="108" y="253" fill="#c9dbed" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="22">Godot 4.7 · GDScript</text>
<text x="108" y="302" fill="#8ca8c5" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="18">相机 · 平行光 · 天空环境</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,18 @@
{
"id": "godot-empty-3d",
"title": "Godot 空白 3D 场景",
"summary": "Godot 4.7 原生三维空白工程:相机、平行光、天空环境与一个自转立方体已就绪,可直接开始搭建场景。",
"tags": [
"空白",
"起步工程",
"3d",
"godot"
],
"runtime": "godot",
"engine": "godot",
"engineVersion": "4.7",
"templateVersion": "0.1.0",
"entry": "project.godot",
"coverWidth": 960,
"coverHeight": 540
}

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