Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01ebc3a143 | |||
| 81419a84bd | |||
| b6847a3232 | |||
| 35c0859ae8 | |||
| 89a901e329 | |||
| 46024b5043 | |||
| 8484a30d49 | |||
| c6a0844b50 | |||
| 5c648ab9c7 | |||
| c7ac4f3bab | |||
| 155f0d316a |
@@ -30,6 +30,7 @@ The hosted MCP offers the following tools. Choose the task tool when its action
|
||||
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
|
||||
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
|
||||
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
|
||||
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
|
||||
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
|
||||
@@ -89,6 +90,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Capability | POST path | Required body fields | Common optional body fields |
|
||||
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
|
||||
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
@@ -98,7 +100,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
|
||||
Poll all nine through:
|
||||
Poll all ten through:
|
||||
|
||||
```text
|
||||
GET /api/external/v1/generations/{operationId}
|
||||
@@ -140,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
|
||||
Use OpenAPI as the final authority; these common values are a routing aid:
|
||||
|
||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
||||
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
||||
- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
|
||||
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||
|
||||
@@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
|
||||
|
||||
## Art Spec and Image Request
|
||||
|
||||
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
|
||||
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
|
||||
|
||||
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
|
||||
|
||||
|
||||
@@ -57,8 +57,6 @@ temp*build*/
|
||||
/apps/ai-game-creator-shell/logs/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
|
||||
/apps/ai-game-creator-shell/.llm-drafts/
|
||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||
/apps/mobile-shell/.expo/
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||
"bundled-resources:check": "node scripts/check-package-layout.mjs",
|
||||
"bundled-resources:sync": "node scripts/check-package-layout.mjs --write",
|
||||
"bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs",
|
||||
"bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
|
||||
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
|
||||
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
|
||||
"config": "node scripts/game-creator-config-wizard.mjs",
|
||||
@@ -28,7 +24,7 @@
|
||||
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
|
||||
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
|
||||
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cubone/react-file-manager": "^1.35.0",
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
resolveReleaseChannel,
|
||||
} from './channel-identity.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { prepareBundledResources } from './prepare-bundled-resources.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -432,29 +431,10 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。
|
||||
export function stageBundledResources(
|
||||
target,
|
||||
{ prepare = prepareBundledResources } = {},
|
||||
) {
|
||||
const summaries = prepare({
|
||||
target,
|
||||
features: new Set(defaultEditorFeatures(target)),
|
||||
log: (line) => console.log(`[ai-game-creator-shell] ${line}`),
|
||||
});
|
||||
for (const summary of summaries) {
|
||||
console.log(`[ai-game-creator-shell] ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runTauriBuild(
|
||||
args = [],
|
||||
context = resolveReleaseContext(args),
|
||||
{
|
||||
spawn = spawnSync,
|
||||
stageRuntime = stageNodeRuntime,
|
||||
stageBundled = stageBundledResources,
|
||||
} = {},
|
||||
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
||||
) {
|
||||
if (
|
||||
explicitBuildTarget(args) &&
|
||||
@@ -464,10 +444,7 @@ export function runTauriBuild(
|
||||
}
|
||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||
const { channel, target } = context;
|
||||
if (!args.includes('--no-bundle')) {
|
||||
stageRuntime(target);
|
||||
stageBundled(target);
|
||||
}
|
||||
if (!args.includes('--no-bundle')) stageRuntime(target);
|
||||
const configPath = writeChannelConfigFile(
|
||||
channel,
|
||||
target,
|
||||
|
||||
@@ -363,8 +363,6 @@ test('packaged renderer receives the same channel as the updater manifest', () =
|
||||
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
|
||||
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
|
||||
stageRuntime: () => {},
|
||||
// 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, _args, options) => {
|
||||
spawnOptions = options;
|
||||
return { status: 0 };
|
||||
@@ -458,8 +456,6 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
||||
seenContexts.push(context);
|
||||
runTauriBuild(args, context, {
|
||||
stageRuntime: () => {},
|
||||
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const configIndex = command.lastIndexOf('--config');
|
||||
const config = JSON.parse(
|
||||
@@ -709,7 +705,6 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
context,
|
||||
{
|
||||
stageRuntime: () => {},
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
assert.ok(
|
||||
command.includes(
|
||||
@@ -823,10 +818,6 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('stage');
|
||||
},
|
||||
stageBundled(target) {
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('bundled');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
events.push('build');
|
||||
const config = JSON.parse(
|
||||
@@ -838,14 +829,11 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, ['stage', 'bundled', 'build']);
|
||||
assert.deepEqual(events, ['stage', 'build']);
|
||||
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
|
||||
stageRuntime() {
|
||||
assert.fail('no-bundle must not stage resources');
|
||||
},
|
||||
stageBundled() {
|
||||
assert.fail('no-bundle must not stage bundled resources');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
const config = JSON.parse(
|
||||
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||
@@ -860,9 +848,6 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
stageRuntime() {
|
||||
throw new Error('missing runtime');
|
||||
},
|
||||
stageBundled() {
|
||||
assert.fail('invalid runtime must prevent bundled staging');
|
||||
},
|
||||
spawn() {
|
||||
assert.fail('invalid runtime must prevent build');
|
||||
},
|
||||
@@ -975,8 +960,6 @@ for (const channel of ['release', 'beta-2']) {
|
||||
);
|
||||
runTauriBuild([`--target=${target}`], context, {
|
||||
stageRuntime: () => {},
|
||||
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,460 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
DECLARATION_PATH,
|
||||
findCodexSource,
|
||||
pluginDirectories,
|
||||
prepareBundledResources,
|
||||
readDeclaration,
|
||||
resolveHostTarget,
|
||||
stagingUnit,
|
||||
} from './prepare-bundled-resources.mjs';
|
||||
|
||||
const WINDOWS_TARGET = 'x86_64-pc-windows-msvc';
|
||||
const MAC_TARGET = 'aarch64-apple-darwin';
|
||||
|
||||
function sha256File(file) {
|
||||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。
|
||||
function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-'));
|
||||
const appRoot = path.join(root, 'app');
|
||||
const repoRoot = path.join(root, 'repo');
|
||||
const destinationRoot = path.join(appRoot, 'src-tauri');
|
||||
const declaration = readDeclaration(DECLARATION_PATH);
|
||||
const lockfile = { packages: {} };
|
||||
|
||||
for (const target of targets) {
|
||||
const layout = declaration.codex.targets.find(
|
||||
(entry) => entry.target === target,
|
||||
);
|
||||
assert.ok(layout, `声明缺少目标 ${target}`);
|
||||
const vendor = path.join(
|
||||
appRoot,
|
||||
`node_modules/@openai/codex-${layout.platform}/vendor/${target}`,
|
||||
);
|
||||
for (const relative of layout.files) {
|
||||
const file = path.join(vendor, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
relative === declaration.codex.packageMetadataFileName
|
||||
? `${JSON.stringify(
|
||||
{
|
||||
layoutVersion: declaration.codex.packageMetadata.layoutVersion,
|
||||
version: declaration.codex.version,
|
||||
target,
|
||||
entrypoint: layout.executable,
|
||||
resourcesDir: declaration.codex.packageMetadata.resourcesDir,
|
||||
pathDir: declaration.codex.packageMetadata.pathDir,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`
|
||||
: `component ${target} ${relative}\n`,
|
||||
);
|
||||
if (relative === layout.executable) {
|
||||
fs.chmodSync(file, 0o755);
|
||||
}
|
||||
}
|
||||
lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = {
|
||||
resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`,
|
||||
integrity: `sha512-${target}`,
|
||||
};
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), {
|
||||
recursive: true,
|
||||
});
|
||||
for (const entry of declaration.codex.noticeSources) {
|
||||
if (entry.preserve) {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(destinationRoot, entry.source);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'mac codex notice\n');
|
||||
}
|
||||
if (targets.includes(WINDOWS_TARGET)) {
|
||||
const tracked = path.join(
|
||||
destinationRoot,
|
||||
'resources/codex/win-x64/NOTICE.md',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(tracked), { recursive: true });
|
||||
fs.writeFileSync(tracked, 'windows codex notice\n');
|
||||
}
|
||||
|
||||
if (plugins) {
|
||||
const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor');
|
||||
fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'plugin.json'),
|
||||
'{"name":"agc-demo-editor"}\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'src/entry.mjs'),
|
||||
'export const entry = 1;\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'panels/panel.html'),
|
||||
'<html></html>\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n');
|
||||
fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n');
|
||||
fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n');
|
||||
fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n');
|
||||
}
|
||||
|
||||
const lockfilePath = path.join(root, 'package-lock.json');
|
||||
fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2));
|
||||
return {
|
||||
root,
|
||||
appRoot,
|
||||
repoRoot,
|
||||
destinationRoot,
|
||||
lockfilePath,
|
||||
recordPath: path.join(root, 'record.json'),
|
||||
declaration,
|
||||
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(directory) {
|
||||
const entries = [];
|
||||
const stack = [['', directory]];
|
||||
while (stack.length > 0) {
|
||||
const [prefix, current] = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push([relative, full]);
|
||||
} else {
|
||||
const info = fs.statSync(full);
|
||||
entries.push({
|
||||
relative,
|
||||
size: info.size,
|
||||
mtimeMs: info.mtimeMs,
|
||||
mode: info.mode & 0o777,
|
||||
sha256: sha256File(full),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
left.relative.localeCompare(right.relative),
|
||||
);
|
||||
}
|
||||
|
||||
function prepare(fixture, overrides = {}) {
|
||||
return prepareBundledResources({
|
||||
target: WINDOWS_TARGET,
|
||||
destinationRoot: fixture.destinationRoot,
|
||||
declarationPath: DECLARATION_PATH,
|
||||
recordPath: fixture.recordPath,
|
||||
lockfilePath: fixture.lockfilePath,
|
||||
repoRoot: fixture.repoRoot,
|
||||
appRoot: fixture.appRoot,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('stages declared codex components with manifest and preserved notice', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const summaries = prepare(fixture);
|
||||
assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/);
|
||||
|
||||
const declaration = fixture.declaration;
|
||||
const layout = declaration.codex.targets.find(
|
||||
(entry) => entry.target === WINDOWS_TARGET,
|
||||
);
|
||||
const unit = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex',
|
||||
layout.directory,
|
||||
);
|
||||
for (const relative of layout.files) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(unit, relative)),
|
||||
`缺少组件 ${relative}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'),
|
||||
'windows codex notice\n',
|
||||
'受版本控制的第三方声明必须原地保留',
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(Object.keys(manifest), [
|
||||
'files',
|
||||
'platform',
|
||||
'schemaVersion',
|
||||
'version',
|
||||
]);
|
||||
assert.equal(manifest.platform, layout.platform);
|
||||
assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema);
|
||||
assert.equal(
|
||||
manifest.version,
|
||||
`${declaration.codex.cliVersionPrefix}${declaration.codex.version}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(manifest.files).sort(),
|
||||
[...layout.files].sort(),
|
||||
'清单文件集合必须等于组件白名单',
|
||||
);
|
||||
for (const relative of layout.files) {
|
||||
assert.equal(
|
||||
manifest.files[relative],
|
||||
sha256File(path.join(unit, relative)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('second run is a no-op: identical content and timestamps', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
prepare(fixture);
|
||||
const unit = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex',
|
||||
'win-x64',
|
||||
);
|
||||
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
|
||||
const before = { codex: snapshot(unit), plugins: snapshot(plugins) };
|
||||
const summaries = prepare(fixture);
|
||||
assert.match(summaries[0], /命中缓存/);
|
||||
assert.match(summaries[1], /命中缓存/);
|
||||
assert.deepEqual(
|
||||
snapshot(unit),
|
||||
before.codex,
|
||||
'codex 产物内容与时间戳必须不变',
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot(plugins),
|
||||
before.plugins,
|
||||
'插件产物内容与时间戳必须不变',
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('stages the macOS universal group with both architectures', () => {
|
||||
const fixture = buildFixture({
|
||||
targets: [MAC_TARGET, 'x86_64-apple-darwin'],
|
||||
});
|
||||
try {
|
||||
const summaries = prepare(fixture, { target: MAC_TARGET });
|
||||
assert.match(summaries[0], /mac-native/);
|
||||
const unit = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex/mac-native',
|
||||
);
|
||||
for (const directory of ['darwin-arm64', 'darwin-x64']) {
|
||||
for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(unit, directory, file)),
|
||||
`缺少 ${directory}/${file}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'),
|
||||
'mac codex notice\n',
|
||||
);
|
||||
}
|
||||
assert.deepEqual(fs.readdirSync(unit).sort(), [
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
]);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('copies only whitelisted plugin subdirectories', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
prepare(fixture);
|
||||
const staged = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/plugins/agc-demo-editor',
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(staged, 'plugin.json')));
|
||||
assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs')));
|
||||
assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html')));
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(staged, 'panels/panel.test.mjs')),
|
||||
'测试文件不随包',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(staged, 'target')),
|
||||
'构建产物目录不随包',
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包');
|
||||
assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包');
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the upstream package metadata drifts from the declaration', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const declaration = fixture.declaration;
|
||||
const layout = declaration.codex.targets.find(
|
||||
(entry) => entry.target === WINDOWS_TARGET,
|
||||
);
|
||||
const metadataFile = path.join(
|
||||
fixture.appRoot,
|
||||
`node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}/codex-package.json`,
|
||||
);
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
|
||||
assert.equal(metadata.version, declaration.codex.version);
|
||||
for (const [key, value] of [
|
||||
['version', '0.0.0'],
|
||||
['layoutVersion', 2],
|
||||
['entrypoint', 'bin/other.exe'],
|
||||
['resourcesDir', '../private'],
|
||||
]) {
|
||||
fs.writeFileSync(
|
||||
metadataFile,
|
||||
`${JSON.stringify({ ...metadata, [key]: value }, null, 2)}\n`,
|
||||
);
|
||||
assert.throws(
|
||||
() => prepare(fixture),
|
||||
/上游包元数据与声明不一致/,
|
||||
`${key} 漂移必须被拒绝`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
!fs.existsSync(
|
||||
path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex/win-x64/manifest.json',
|
||||
),
|
||||
),
|
||||
'拒绝时不得留下产物',
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the upstream package is missing', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
fs.rmSync(path.join(fixture.appRoot, 'node_modules'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
assert.throws(() => prepare(fixture), /npm ci/);
|
||||
assert.ok(
|
||||
!fs.existsSync(
|
||||
path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex/win-x64/manifest.json',
|
||||
),
|
||||
),
|
||||
'失败时不得留下半成品清单',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for unsupported targets', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
assert.throws(
|
||||
() => prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }),
|
||||
/声明不含目标/,
|
||||
);
|
||||
assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the destination is owned by something else', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
|
||||
fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n');
|
||||
assert.throws(() => prepare(fixture), /被非本工具内容占用/);
|
||||
|
||||
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
|
||||
fs.rmSync(path.join(unit, 'foreign.bin'), { force: true });
|
||||
fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), {
|
||||
recursive: true,
|
||||
});
|
||||
assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dry run writes nothing', () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const summaries = prepare(fixture, { dryRun: true });
|
||||
assert.match(summaries[0], /需要重新生成(dry-run 未写入)/);
|
||||
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(unit),
|
||||
['NOTICE.md'],
|
||||
'dry-run 不得写入任何组件或清单',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
|
||||
);
|
||||
assert.ok(!fs.existsSync(fixture.recordPath));
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('declaration drives source lookup and staging units', () => {
|
||||
const declaration = readDeclaration(DECLARATION_PATH);
|
||||
const windows = stagingUnit(declaration, WINDOWS_TARGET);
|
||||
assert.equal(windows.directory, 'win-x64');
|
||||
assert.deepEqual(
|
||||
windows.targets.map((member) => member.target),
|
||||
[WINDOWS_TARGET],
|
||||
);
|
||||
const mac = stagingUnit(declaration, MAC_TARGET);
|
||||
assert.equal(mac.directory, 'mac-native');
|
||||
assert.deepEqual(
|
||||
mac.targets.map((member) => member.target),
|
||||
['aarch64-apple-darwin', 'x86_64-apple-darwin'],
|
||||
);
|
||||
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const source = findCodexSource(declaration, WINDOWS_TARGET, {
|
||||
app: fixture.appRoot,
|
||||
repo: fixture.repoRoot,
|
||||
});
|
||||
assert.match(source, /codex-win32-x64\/vendor\/x86_64-pc-windows-msvc$/);
|
||||
assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs';
|
||||
import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
@@ -11,10 +10,6 @@ import {
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
} from './dev-port.mjs';
|
||||
import {
|
||||
prepareBundledResources,
|
||||
supportedHostTarget,
|
||||
} from './prepare-bundled-resources.mjs';
|
||||
import {
|
||||
isAiGameCreatorServer,
|
||||
preflightExistingVite,
|
||||
@@ -72,29 +67,6 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
|
||||
return withDefaultCargoFeatures(argv, features);
|
||||
}
|
||||
|
||||
/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。
|
||||
/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。
|
||||
function prepareBundledResourcesBeforeTauri(
|
||||
features = readDevCargoFeatures(),
|
||||
{ prepare = prepareBundledResources, log = console.log } = {},
|
||||
) {
|
||||
const target = supportedHostTarget();
|
||||
if (!target) {
|
||||
log(
|
||||
'[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const summaries = prepare({
|
||||
target,
|
||||
features: new Set(features),
|
||||
log: (line) => log(`[ai-game-creator-shell] ${line}`),
|
||||
});
|
||||
for (const summary of summaries) {
|
||||
log(`[ai-game-creator-shell] ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnTauriCli(argv, { env = process.env } = {}) {
|
||||
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
|
||||
cwd: appRoot,
|
||||
@@ -103,17 +75,6 @@ function spawnTauriCli(argv, { env = process.env } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的
|
||||
/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态
|
||||
/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用
|
||||
/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。
|
||||
function buildTauriDevProcessEnv(endpoint, env = process.env) {
|
||||
return buildLocalRustProcessEnv({
|
||||
...withAgcDevEndpointEnv(endpoint, env),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
async function runTauriDev(
|
||||
argv = process.argv.slice(2),
|
||||
{
|
||||
@@ -123,7 +84,6 @@ async function runTauriDev(
|
||||
spawnCli = spawnTauriCli,
|
||||
waitForCli = waitForChildTermination,
|
||||
terminateTree = terminateChildTree,
|
||||
prepareResources = prepareBundledResourcesBeforeTauri,
|
||||
} = {},
|
||||
) {
|
||||
const endpoint = await resolveDevEndpoint();
|
||||
@@ -170,14 +130,15 @@ async function runTauriDev(
|
||||
shutdownRequested.then(() => false),
|
||||
]);
|
||||
if (!prepared || shutdownSignal) return 1;
|
||||
const devFeatures = readDevCargoFeatures();
|
||||
prepareResources(devFeatures);
|
||||
const tauriArguments = buildTauriArguments(
|
||||
withDevCargoFeatures(argv, devFeatures),
|
||||
withDevCargoFeatures(argv),
|
||||
endpoint.url,
|
||||
);
|
||||
child = spawnCli(tauriArguments, {
|
||||
env: buildTauriDevProcessEnv(endpoint),
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
},
|
||||
});
|
||||
const childResult = waitForCli(child);
|
||||
const outcome = await Promise.race([
|
||||
@@ -264,9 +225,7 @@ function isDirectModuleExecution() {
|
||||
|
||||
export {
|
||||
buildTauriArguments,
|
||||
buildTauriDevProcessEnv,
|
||||
isDirectModuleExecution,
|
||||
prepareBundledResourcesBeforeTauri,
|
||||
runTauriDev,
|
||||
spawnTauriCli,
|
||||
withDevCargoFeatures,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// 构建脚本只用布局里的目录与校验入口(写入分支已移交准备步骤),
|
||||
// 其余字段与常量供运行期使用,因此这里不报构建上下文里的 dead_code。
|
||||
#[allow(dead_code)]
|
||||
#[path = "build_support/codex_bundle.rs"]
|
||||
mod codex_bundle;
|
||||
#[path = "build_support/codex_package_metadata.rs"]
|
||||
mod codex_package_metadata;
|
||||
#[path = "build_support/frontend_dist_guard.rs"]
|
||||
mod frontend_dist_guard;
|
||||
#[path = "build_support/godot_bundle.rs"]
|
||||
@@ -16,7 +15,164 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_bundle::package_layout;
|
||||
use std::io::{BufReader, Read};
|
||||
|
||||
fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
|
||||
let file = fs::File::open(path)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = reader.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
|
||||
let target = env::var("TARGET").expect("Cargo TARGET");
|
||||
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
|
||||
if target.contains("apple-darwin") {
|
||||
// Tauri 的 universal 两次 Cargo 编译共用 resource staging,
|
||||
// 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。
|
||||
let staging = manifest_dir.join("resources/codex/mac-native");
|
||||
if staging.exists() {
|
||||
fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败");
|
||||
}
|
||||
for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
|
||||
stage_codex_target(manifest_dir, target);
|
||||
}
|
||||
} else {
|
||||
stage_codex_target(manifest_dir, &target);
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
|
||||
let Some(layout) = codex_bundle::for_target(target) else {
|
||||
assert!(
|
||||
!target.contains("windows") && !target.contains("apple-darwin"),
|
||||
"不支持的 Codex 随包目标:{target}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
{
|
||||
let app_root = manifest_dir
|
||||
.parent()
|
||||
.expect("AI 游戏创作 Tauri manifest 必须位于应用目录下");
|
||||
let repo_root = app_root
|
||||
.parent()
|
||||
.and_then(|apps_dir| apps_dir.parent())
|
||||
.expect("AI 游戏创作应用必须位于仓库 apps 目录下");
|
||||
let package = format!("codex-{}", layout.platform);
|
||||
let source_candidates = [app_root, repo_root]
|
||||
.into_iter()
|
||||
.flat_map(|root| {
|
||||
[
|
||||
root.join(format!("node_modules/@openai/{package}/vendor/{target}")),
|
||||
root.join(format!(
|
||||
"node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}"
|
||||
)),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let source = source_candidates
|
||||
.iter()
|
||||
.find(|path| {
|
||||
layout
|
||||
.files
|
||||
.iter()
|
||||
.all(|relative| path.join(relative).is_file())
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})",
|
||||
source_candidates
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
)
|
||||
});
|
||||
let metadata: serde_json::Value = serde_json::from_slice(
|
||||
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
|
||||
)
|
||||
.expect("Codex 原生包元数据无效");
|
||||
codex_package_metadata::validate_package_metadata(&metadata, target, layout)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
|
||||
let notice = target_dir.join("NOTICE.md");
|
||||
if target.contains("apple-darwin") {
|
||||
let source_notice =
|
||||
manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md");
|
||||
stage_plugin_file(&source_notice, ¬ice);
|
||||
println!("cargo:rerun-if-changed={}", source_notice.display());
|
||||
}
|
||||
if !notice.is_file() {
|
||||
panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display());
|
||||
}
|
||||
fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败");
|
||||
let mut file_hashes = serde_json::Map::new();
|
||||
for relative in layout.files {
|
||||
let source_path = source.join(relative);
|
||||
let target_path = target_dir.join(relative);
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败");
|
||||
}
|
||||
let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败");
|
||||
let target_matches_source = target_path.is_file()
|
||||
&& sha256_file(&target_path)
|
||||
.map(|target_sha256| target_sha256 == source_sha256)
|
||||
.unwrap_or(false);
|
||||
let source_permissions = fs::metadata(&source_path)
|
||||
.expect("读取组件权限失败")
|
||||
.permissions();
|
||||
if !target_matches_source {
|
||||
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
|
||||
fs::set_permissions(&target_path, source_permissions.clone())
|
||||
.expect("保留内置 Codex CLI 组件权限失败");
|
||||
} else if fs::metadata(&target_path)
|
||||
.expect("读取内置 Codex CLI 资源失败")
|
||||
.permissions()
|
||||
!= source_permissions
|
||||
{
|
||||
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
|
||||
// 权限已一致时不再写元数据:Windows 上这次写入会更新 change time,
|
||||
// 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。
|
||||
fs::set_permissions(&target_path, source_permissions)
|
||||
.expect("保留内置 Codex CLI 组件权限失败");
|
||||
}
|
||||
file_hashes.insert(
|
||||
relative.to_string(),
|
||||
serde_json::Value::String(source_sha256),
|
||||
);
|
||||
}
|
||||
let manifest = serde_json::json!({
|
||||
"schemaVersion": codex_bundle::SCHEMA,
|
||||
"platform": layout.platform,
|
||||
"version": codex_bundle::CLI_VERSION,
|
||||
"files": file_hashes,
|
||||
});
|
||||
let manifest_path = target_dir.join("manifest.json");
|
||||
let manifest_payload = format!(
|
||||
"{}\n",
|
||||
serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败")
|
||||
);
|
||||
if fs::read_to_string(&manifest_path)
|
||||
.map(|current| current != manifest_payload)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败");
|
||||
}
|
||||
for relative in layout.files {
|
||||
println!("cargo:rerun-if-changed={}", source.join(relative).display());
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", notice.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_task_group_id(
|
||||
group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup,
|
||||
@@ -61,58 +217,16 @@ fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBu
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读校验:确认已经落盘的随包产物与声明一致。本函数不写任何文件。
|
||||
fn validate_staged_resources(manifest_dir: &std::path::Path) {
|
||||
let target = env::var("TARGET").expect("Cargo TARGET");
|
||||
for staged_target in package_layout::staged_targets(&target) {
|
||||
let Some(layout) = codex_bundle::for_target(staged_target) else {
|
||||
continue;
|
||||
};
|
||||
let target_dir = manifest_dir
|
||||
.join(package_layout::codex().resource_directory)
|
||||
.join(layout.directory);
|
||||
package_layout::validate_staged_codex_bundle(&target_dir, staged_target).unwrap_or_else(
|
||||
|error| panic!("内置 Codex CLI 随包资源校验失败({staged_target}):{error}"),
|
||||
);
|
||||
}
|
||||
validate_staged_plugin_workspace(manifest_dir, &target);
|
||||
}
|
||||
|
||||
/// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。
|
||||
fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) {
|
||||
let declared = package_layout::plugins();
|
||||
let repo_root = manifest_dir
|
||||
.parent()
|
||||
.and_then(|app_root| app_root.parent())
|
||||
.and_then(|apps_dir| apps_dir.parent())
|
||||
.expect("AGC 应用必须位于仓库 apps 目录下");
|
||||
package_layout::validate_staged_plugins(
|
||||
&repo_root.join(declared.source_directory),
|
||||
&manifest_dir.join(declared.destination_directory),
|
||||
target,
|
||||
package_layout::cargo_feature_enabled,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}"));
|
||||
}
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(
|
||||
env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"),
|
||||
);
|
||||
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
|
||||
// 运行期定位随包目录依赖该编译期常量,与是否跳过 staging 无关(见技术方案 §4.4)。
|
||||
println!(
|
||||
"cargo:rustc-env=AGC_BUILD_TARGET={}",
|
||||
env::var("TARGET").expect("Cargo TARGET")
|
||||
);
|
||||
// AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成),
|
||||
// 用于在既有产物上单独验证校验路径。
|
||||
if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() {
|
||||
prepare_unity_editor_helper(&manifest_dir);
|
||||
prepare_godot_editor_extension(&manifest_dir);
|
||||
stage_build_generated_plugin_payloads(&manifest_dir);
|
||||
stage_cocos_editor_payload(&manifest_dir);
|
||||
}
|
||||
validate_staged_resources(&manifest_dir);
|
||||
stage_bundled_codex_cli(&manifest_dir);
|
||||
prepare_unity_editor_helper(&manifest_dir);
|
||||
prepare_godot_editor_extension(&manifest_dir);
|
||||
stage_plugin_workspace(&manifest_dir);
|
||||
stage_cocos_editor_payload(&manifest_dir);
|
||||
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
|
||||
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
|
||||
validate_seed_task_catalog(&compiled);
|
||||
@@ -315,101 +429,134 @@ fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) {
|
||||
godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
/// 构建期产物归位:只有构建过程才产出、因而无法由准备步骤生成的随包子目录。
|
||||
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
|
||||
///
|
||||
/// 源码派生的子目录由准备步骤在 `tauri dev|build` 之前写入;这里只补构建期才存在的产物。
|
||||
/// 把这批产物也归位到准备步骤(连同编辑器分支产物)在后续里程碑完成。
|
||||
fn stage_build_generated_plugin_payloads(manifest_dir: &std::path::Path) {
|
||||
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
|
||||
/// Cargo target 目录或 node_modules。
|
||||
fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
|
||||
let target = env::var("TARGET").expect("Cargo TARGET");
|
||||
if !package_layout::plugin_staging_applies(&target) {
|
||||
if !target.contains("windows") && !target.contains("apple-darwin") {
|
||||
return;
|
||||
}
|
||||
let declared = package_layout::plugins();
|
||||
let repo_root = manifest_dir
|
||||
.parent()
|
||||
.and_then(|app_root| app_root.parent())
|
||||
.and_then(|apps_dir| apps_dir.parent())
|
||||
.expect("AGC 应用必须位于仓库 apps 目录下");
|
||||
let destination_root = manifest_dir.join(declared.destination_directory);
|
||||
let plugins = package_layout::plugin_directories(
|
||||
&repo_root.join(declared.source_directory),
|
||||
declared.manifest_file_name,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
for plugin in plugins {
|
||||
for subdirectory in declared.subdirectories {
|
||||
if !package_layout::subdirectory_is_build_derived(subdirectory)
|
||||
|| !package_layout::subdirectory_enabled(
|
||||
subdirectory,
|
||||
&target,
|
||||
package_layout::cargo_feature_enabled,
|
||||
)
|
||||
.expect("AGC 应用必须位于仓库 apps 目录下")
|
||||
.to_path_buf();
|
||||
let workspace = repo_root.join("plugins");
|
||||
let destination_root = manifest_dir.join("resources/plugins");
|
||||
// staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。
|
||||
if destination_root.exists() {
|
||||
std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败");
|
||||
}
|
||||
std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
|
||||
let entries = match std::fs::read_dir(&workspace) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let plugin_root = entry.path();
|
||||
assert!(
|
||||
!entry
|
||||
.file_type()
|
||||
.expect("读取插件目录类型失败")
|
||||
.is_symlink(),
|
||||
"插件工作区不允许符号链接"
|
||||
);
|
||||
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let destination = destination_root.join(&name);
|
||||
copy_plugin_file(
|
||||
&plugin_root.join("plugin.json"),
|
||||
&destination.join("plugin.json"),
|
||||
);
|
||||
for relative in [
|
||||
std::path::PathBuf::from("src"),
|
||||
std::path::PathBuf::from("panels"),
|
||||
std::path::PathBuf::from("skills"),
|
||||
std::path::PathBuf::from("native/payload"),
|
||||
std::path::PathBuf::from("dotnet/publish/win-x64"),
|
||||
] {
|
||||
if (relative == std::path::Path::new("native/payload") && !target.contains("windows"))
|
||||
|| (relative == std::path::Path::new("dotnet/publish/win-x64")
|
||||
&& (target != "x86_64-pc-windows-msvc"
|
||||
|| env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let relative = package_layout::declared_relative_path(subdirectory.path);
|
||||
let source = plugin.path.join(&relative);
|
||||
if !source.is_dir() {
|
||||
continue;
|
||||
}
|
||||
copy_staged_tree(
|
||||
&source,
|
||||
&destination_root.join(&plugin.name).join(&relative),
|
||||
);
|
||||
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
|
||||
}
|
||||
for staging in declared.library_staging {
|
||||
if plugin.name != staging.plugin
|
||||
|| !package_layout::library_staging_enabled(
|
||||
staging,
|
||||
&target,
|
||||
package_layout::cargo_feature_enabled,
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let relative = package_layout::declared_relative_path(staging.source_subdirectory);
|
||||
match staging.layout {
|
||||
"godot-bundle" => godot_bundle::stage(
|
||||
&plugin.path.join(&relative),
|
||||
&destination_root.join(&plugin.name).join(&relative),
|
||||
&target,
|
||||
true,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
other => panic!("未实现的随包库 staging 布局:{other}"),
|
||||
}
|
||||
if name == "agc-godot-editor" {
|
||||
godot_bundle::stage(
|
||||
&plugin_root.join("native/gdextension"),
|
||||
&destination.join("native/gdextension"),
|
||||
&target,
|
||||
env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", plugin_root.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制一棵目录树(按声明跳过构建产物与测试文件);内容一致时不重写。
|
||||
fn copy_staged_tree(source: &std::path::Path, destination: &std::path::Path) {
|
||||
if !source.is_dir() {
|
||||
fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
|
||||
let bytes = std::fs::read(source)
|
||||
.unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display()));
|
||||
if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
|
||||
return;
|
||||
}
|
||||
fs::create_dir_all(destination).expect("创建插件资源目录失败");
|
||||
for entry in fs::read_dir(source).expect("读取插件资源失败").flatten() {
|
||||
if let Some(parent) = destination.parent() {
|
||||
std::fs::create_dir_all(parent).expect("创建插件资源目录失败");
|
||||
}
|
||||
std::fs::write(destination, bytes).expect("复制插件资源失败");
|
||||
}
|
||||
|
||||
fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
|
||||
let entries = match std::fs::read_dir(source) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let target = destination.join(entry.file_name());
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy().to_string();
|
||||
let target = destination.join(&file_name);
|
||||
assert!(
|
||||
entry
|
||||
!entry
|
||||
.file_type()
|
||||
.expect("读取插件文件类型失败")
|
||||
.is_symlink(),
|
||||
"插件资源不允许符号链接"
|
||||
);
|
||||
if path.is_dir() {
|
||||
if !package_layout::skip_directory(&name) {
|
||||
copy_staged_tree(&path, &target);
|
||||
}
|
||||
} else if !package_layout::skip_file_name(&name) {
|
||||
let bytes = fs::read(&path).expect("读取插件资源失败");
|
||||
if fs::read(&target).is_ok_and(|existing| existing == bytes) {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") {
|
||||
continue;
|
||||
}
|
||||
fs::write(&target, bytes).expect("复制插件资源失败");
|
||||
std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
|
||||
copy_plugin_tree(&path, &target);
|
||||
} else {
|
||||
// 测试文件不随包分发。
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.contains(".test.") {
|
||||
continue;
|
||||
}
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
stage_plugin_file(&path, &target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
|
||||
if !source.is_file() {
|
||||
return;
|
||||
}
|
||||
std::fs::create_dir_all(destination.parent().expect("插件资源父目录"))
|
||||
.expect("创建插件资源目录失败");
|
||||
std::fs::copy(source, destination).expect("复制插件资源失败");
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
|
||||
//!
|
||||
//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json`
|
||||
//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量,
|
||||
//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。
|
||||
//! 本模块只读声明,不写任何随包资源。
|
||||
|
||||
// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。
|
||||
#[allow(dead_code)]
|
||||
#[path = "package_layout.rs"]
|
||||
pub(crate) mod package_layout;
|
||||
|
||||
pub const VERSION: &str = package_layout::CODEX_VERSION;
|
||||
pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION;
|
||||
pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA;
|
||||
pub const VERSION: &str = "0.155.1";
|
||||
pub const CLI_VERSION: &str = "codex-cli 0.155.1";
|
||||
pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Layout {
|
||||
@@ -22,13 +12,46 @@ pub struct Layout {
|
||||
pub files: &'static [&'static str],
|
||||
}
|
||||
|
||||
const WINDOWS_FILES: &[&str] = &[
|
||||
"bin/codex.exe",
|
||||
"bin/codex-code-mode-host.exe",
|
||||
"codex-path/rg.exe",
|
||||
"codex-resources/codex-command-runner.exe",
|
||||
"codex-resources/codex-windows-sandbox-setup.exe",
|
||||
"codex-package.json",
|
||||
];
|
||||
const MAC_FILES: &[&str] = &[
|
||||
"bin/codex",
|
||||
"bin/codex-code-mode-host",
|
||||
"codex-path/rg",
|
||||
"codex-resources/zsh/bin/zsh",
|
||||
"codex-package.json",
|
||||
];
|
||||
|
||||
pub fn for_target(target: &str) -> Option<Layout> {
|
||||
package_layout::codex_target(target).map(|declared| Layout {
|
||||
platform: declared.platform,
|
||||
directory: declared.directory,
|
||||
executable: declared.executable,
|
||||
files: declared.files,
|
||||
})
|
||||
match target {
|
||||
"x86_64-pc-windows-msvc" => Some(Layout {
|
||||
platform: "win32-x64",
|
||||
directory: "win-x64",
|
||||
executable: "bin/codex.exe",
|
||||
files: WINDOWS_FILES,
|
||||
}),
|
||||
"aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout {
|
||||
platform: if target.starts_with("aarch64") {
|
||||
"darwin-arm64"
|
||||
} else {
|
||||
"darwin-x64"
|
||||
},
|
||||
directory: if target.starts_with("aarch64") {
|
||||
"mac-native/darwin-arm64"
|
||||
} else {
|
||||
"mac-native/darwin-x64"
|
||||
},
|
||||
executable: "bin/codex",
|
||||
files: MAC_FILES,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -57,11 +80,4 @@ mod tests {
|
||||
assert!(for_target("aarch64-pc-windows-msvc").is_none());
|
||||
assert!(for_target("x86_64-unknown-linux-gnu").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constants_come_from_the_shared_declaration() {
|
||||
assert_eq!(VERSION, "0.155.1");
|
||||
assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}"));
|
||||
assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 随包阶段的原生包元数据校验,不进入运行时生产模块。
|
||||
|
||||
use super::codex_bundle::{Layout, VERSION};
|
||||
|
||||
pub fn validate_package_metadata(
|
||||
metadata: &serde_json::Value,
|
||||
target: &str,
|
||||
layout: Layout,
|
||||
) -> Result<(), String> {
|
||||
if metadata["layoutVersion"] == 1
|
||||
&& metadata["version"] == VERSION
|
||||
&& metadata["target"] == target
|
||||
&& metadata["entrypoint"] == layout.executable
|
||||
&& metadata["resourcesDir"] == "codex-resources"
|
||||
&& metadata["pathDir"] == "codex-path"
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::codex_bundle::for_target;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn metadata_rejects_version_architecture_and_layout_drift() {
|
||||
let target = "aarch64-apple-darwin";
|
||||
let layout = for_target(target).unwrap();
|
||||
let valid = serde_json::json!({
|
||||
"layoutVersion": 1,
|
||||
"version": VERSION,
|
||||
"target": target,
|
||||
"entrypoint": "bin/codex",
|
||||
"resourcesDir": "codex-resources",
|
||||
"pathDir": "codex-path",
|
||||
});
|
||||
assert!(validate_package_metadata(&valid, target, layout).is_ok());
|
||||
for (key, value) in [
|
||||
("layoutVersion", serde_json::json!(2)),
|
||||
("version", serde_json::json!("0.0.0")),
|
||||
("target", serde_json::json!("x86_64-apple-darwin")),
|
||||
("entrypoint", serde_json::json!("bin/codex.exe")),
|
||||
("resourcesDir", serde_json::json!("../private")),
|
||||
("pathDir", serde_json::json!(null)),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid[key] = value;
|
||||
assert!(
|
||||
validate_package_metadata(&invalid, target, layout).is_err(),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs
|
||||
// 来源:build_support/package-layout.json。不要手工编辑本文件。
|
||||
// 修改随包资源布局请编辑声明文件,然后运行
|
||||
// npm run agc:package-layout:sync(在仓库根目录)
|
||||
// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。
|
||||
|
||||
pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1";
|
||||
pub const LAYOUT_VERSION: u64 = 1;
|
||||
|
||||
pub const CODEX_VERSION: &str = "0.155.1";
|
||||
pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1";
|
||||
pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2";
|
||||
|
||||
pub const CODEX: Codex = Codex {
|
||||
package_metadata: PackageMetadata {
|
||||
layout_version: 1,
|
||||
resources_dir: "codex-resources",
|
||||
path_dir: "codex-path",
|
||||
},
|
||||
resource_directory: "resources/codex",
|
||||
manifest_file_name: "manifest.json",
|
||||
package_metadata_file_name: "codex-package.json",
|
||||
notice_file_name: "NOTICE.md",
|
||||
source_roots: &["app", "repo"],
|
||||
source_relative_paths: &["node_modules/@openai/codex-<platform>/vendor/<target>", "node_modules/@openai/codex/node_modules/@openai/codex-<platform>/vendor/<target>"],
|
||||
notice_sources: &[
|
||||
NoticeSource {
|
||||
targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
|
||||
source: "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
|
||||
preserve: false,
|
||||
},
|
||||
NoticeSource {
|
||||
targets: &["x86_64-pc-windows-msvc"],
|
||||
source: "resources/codex/win-x64/NOTICE.md",
|
||||
preserve: true,
|
||||
}
|
||||
],
|
||||
universal_groups: &[
|
||||
UniversalGroup {
|
||||
name: "mac-native",
|
||||
directory: "mac-native",
|
||||
targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
|
||||
}
|
||||
],
|
||||
targets: &[
|
||||
CodexTarget {
|
||||
target: "x86_64-pc-windows-msvc",
|
||||
platform: "win32-x64",
|
||||
directory: "win-x64",
|
||||
executable: "bin/codex.exe",
|
||||
files: &["bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json"],
|
||||
},
|
||||
CodexTarget {
|
||||
target: "aarch64-apple-darwin",
|
||||
platform: "darwin-arm64",
|
||||
directory: "mac-native/darwin-arm64",
|
||||
executable: "bin/codex",
|
||||
files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
|
||||
},
|
||||
CodexTarget {
|
||||
target: "x86_64-apple-darwin",
|
||||
platform: "darwin-x64",
|
||||
directory: "mac-native/darwin-x64",
|
||||
executable: "bin/codex",
|
||||
files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
pub const PLUGINS: Plugins = Plugins {
|
||||
source_directory: "plugins",
|
||||
destination_directory: "resources/plugins",
|
||||
manifest_file_name: "plugin.json",
|
||||
target_contains_any: &["windows", "apple-darwin"],
|
||||
subdirectories: &[
|
||||
Subdirectory {
|
||||
path: "src",
|
||||
origin: "source",
|
||||
target_contains: &[],
|
||||
targets: &[],
|
||||
features: &[],
|
||||
},
|
||||
Subdirectory {
|
||||
path: "panels",
|
||||
origin: "source",
|
||||
target_contains: &[],
|
||||
targets: &[],
|
||||
features: &[],
|
||||
},
|
||||
Subdirectory {
|
||||
path: "skills",
|
||||
origin: "source",
|
||||
target_contains: &[],
|
||||
targets: &[],
|
||||
features: &[],
|
||||
},
|
||||
Subdirectory {
|
||||
path: "native/payload",
|
||||
origin: "source",
|
||||
target_contains: &["windows"],
|
||||
targets: &[],
|
||||
features: &[],
|
||||
},
|
||||
Subdirectory {
|
||||
path: "dotnet/publish/win-x64",
|
||||
origin: "build",
|
||||
target_contains: &[],
|
||||
targets: &["x86_64-pc-windows-msvc"],
|
||||
features: &["unity-editor-execute"],
|
||||
}
|
||||
],
|
||||
library_staging: &[
|
||||
LibraryStaging {
|
||||
plugin: "agc-godot-editor",
|
||||
source_subdirectory: "native/gdextension",
|
||||
targets: &["x86_64-pc-windows-msvc"],
|
||||
features: &["godot-editor-execute"],
|
||||
layout: "godot-bundle",
|
||||
}
|
||||
],
|
||||
skip_directory_names: &["target", "node_modules"],
|
||||
skip_directory_name_prefixes: &["."],
|
||||
skip_file_name_prefixes: &["."],
|
||||
skip_file_name_fragments: &[".test."],
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"schema": "agc-package-layout.v1",
|
||||
"layoutVersion": 1,
|
||||
"description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 <platform>、<target> 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。",
|
||||
"codex": {
|
||||
"version": "0.155.1",
|
||||
"cliVersionPrefix": "codex-cli ",
|
||||
"manifestSchema": "genarrative-codex-sidecar.v2",
|
||||
"packageMetadata": {
|
||||
"layoutVersion": 1,
|
||||
"resourcesDir": "codex-resources",
|
||||
"pathDir": "codex-path"
|
||||
},
|
||||
"resourceDirectory": "resources/codex",
|
||||
"manifestFileName": "manifest.json",
|
||||
"packageMetadataFileName": "codex-package.json",
|
||||
"noticeFileName": "NOTICE.md",
|
||||
"sourceRoots": ["app", "repo"],
|
||||
"sourceRelativePaths": [
|
||||
"node_modules/@openai/codex-<platform>/vendor/<target>",
|
||||
"node_modules/@openai/codex/node_modules/@openai/codex-<platform>/vendor/<target>"
|
||||
],
|
||||
"noticeSources": [
|
||||
{
|
||||
"targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"],
|
||||
"source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
|
||||
"preserve": false
|
||||
},
|
||||
{
|
||||
"targets": ["x86_64-pc-windows-msvc"],
|
||||
"source": "resources/codex/win-x64/NOTICE.md",
|
||||
"preserve": true
|
||||
}
|
||||
],
|
||||
"universalGroups": [
|
||||
{
|
||||
"name": "mac-native",
|
||||
"directory": "mac-native",
|
||||
"targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"]
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"target": "x86_64-pc-windows-msvc",
|
||||
"platform": "win32-x64",
|
||||
"directory": "win-x64",
|
||||
"executable": "bin/codex.exe",
|
||||
"files": [
|
||||
"bin/codex.exe",
|
||||
"bin/codex-code-mode-host.exe",
|
||||
"codex-path/rg.exe",
|
||||
"codex-resources/codex-command-runner.exe",
|
||||
"codex-resources/codex-windows-sandbox-setup.exe",
|
||||
"codex-package.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "aarch64-apple-darwin",
|
||||
"platform": "darwin-arm64",
|
||||
"directory": "mac-native/darwin-arm64",
|
||||
"executable": "bin/codex",
|
||||
"files": [
|
||||
"bin/codex",
|
||||
"bin/codex-code-mode-host",
|
||||
"codex-path/rg",
|
||||
"codex-resources/zsh/bin/zsh",
|
||||
"codex-package.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "x86_64-apple-darwin",
|
||||
"platform": "darwin-x64",
|
||||
"directory": "mac-native/darwin-x64",
|
||||
"executable": "bin/codex",
|
||||
"files": [
|
||||
"bin/codex",
|
||||
"bin/codex-code-mode-host",
|
||||
"codex-path/rg",
|
||||
"codex-resources/zsh/bin/zsh",
|
||||
"codex-package.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"sourceDirectory": "plugins",
|
||||
"destinationDirectory": "resources/plugins",
|
||||
"manifestFileName": "plugin.json",
|
||||
"targetContainsAny": ["windows", "apple-darwin"],
|
||||
"subdirectories": [
|
||||
{ "path": "src", "origin": "source" },
|
||||
{ "path": "panels", "origin": "source" },
|
||||
{ "path": "skills", "origin": "source" },
|
||||
{ "path": "native/payload", "origin": "source", "targetContains": ["windows"] },
|
||||
{
|
||||
"path": "dotnet/publish/win-x64",
|
||||
"origin": "build",
|
||||
"targets": ["x86_64-pc-windows-msvc"],
|
||||
"features": ["unity-editor-execute"]
|
||||
}
|
||||
],
|
||||
"libraryStaging": [
|
||||
{
|
||||
"plugin": "agc-godot-editor",
|
||||
"sourceSubdirectory": "native/gdextension",
|
||||
"targets": ["x86_64-pc-windows-msvc"],
|
||||
"features": ["godot-editor-execute"],
|
||||
"layout": "godot-bundle"
|
||||
}
|
||||
],
|
||||
"skipDirectoryNames": ["target", "node_modules"],
|
||||
"skipDirectoryNamePrefixes": ["."],
|
||||
"skipFileNamePrefixes": ["."],
|
||||
"skipFileNameFragments": [".test."]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
共享过程文件(如需维护,请使用这些相对路径):
|
||||
- project/analysis.md
|
||||
- project/决策台账.md
|
||||
- project/dialog.md
|
||||
- project/analysis.md:重要取舍的依据与当前结论。
|
||||
- project/决策台账.md:待处理事项与下一步,必要时引用相关文档。
|
||||
- project/dialog.md:仅在用户需要时记录对话摘要或交接信息。
|
||||
正式产物使用当前阶段指定的相对路径。
|
||||
五个策划阶段的审批:当你判断当前策划阶段必需产物已完成时,必须提交阶段审批。用户批准后进入下一阶段。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
概念阶段定稿时,创建或更新 `project/速览卡.md`。下面是速览卡的参考结构;根据游戏类型、项目规模和用户要求选择适用字段,同类内容可以合并,复杂项目可以增加必要字段。表格和列表中的示例行按实际对象逐行扩展:
|
||||
概念阶段定稿时,创建或更新 `project/速览卡.md`,简要介绍当前游戏。后续仅在核心体验、范围、平台等概览内容变化时更新,不复制完整决策清单。下面是速览卡的参考结构;根据游戏类型、项目规模和用户要求选择适用字段,同类内容可以合并,复杂项目可以增加必要字段。表格和列表中的示例行按实际对象逐行扩展:
|
||||
|
||||
# 速览卡:《游戏名》
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
当前阶段:顶层设计。明确玩家持续游玩的循环、资源流、节奏和系统范围。
|
||||
当前阶段:顶层设计。明确游玩过程、关键规则与反馈、版本范围和验证计划,为系统划分提供依据。
|
||||
|
||||
+6
-64
@@ -1,66 +1,8 @@
|
||||
# 决策台账:《星露谷物语》金样项目
|
||||
# 决策台账:《星露谷物语》示例项目
|
||||
|
||||
版本:v3 | 规则:台账放活队列——design 只放结论、分析只放论证、决定与开放问题住这里。编号连续不复用;被推翻的行标 overturned 挂新行,不删行。
|
||||
状态六态:`confirmed`(用户亲口/亲选)/ `auto_decided`(技术类代决,必带理由+推翻条件,用户一键可翻)/ `default_pending`(默认建议兜底,用户未点头)/ `prototype_pending`(待原型验证)/ `pending_user`(等用户拍板)/ `overturned`(被推翻,挂旧行编号)。
|
||||
仅列仍需跟进的事项和下一步。详细依据与采用的规则见相关分析和设计文档;事项完成后移出待办。
|
||||
|
||||
> 编号口径:D-01~D-13 与 templates/stardew-analysis.md 台账节选一致(D-04~D-06、D-08~D-10、D-12 原为"就地小权衡,直接登记未开条目",此处按登记口径展开);D-14 起为技术文档期新增,与 stardew-tdd-tech.md 开放问题回执互引。
|
||||
|
||||
## 当前待办(活队列)
|
||||
|
||||
### 等用户拍板(pending_user)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-14 | 体力与战斗共享单池 | TDD | user | 风险资源统一制造取舍(概念张力一);**暂按共享实现,改单拆只需改 S02 成本入口** | 战斗参与率实测过低(玩家回避矿井) | pending_user(暂按共享实现) |
|
||||
| D-15 | 背包格子制 vs 重量制 | TDD | user | 格子制直觉、重量制焦虑感与 T5"休闲不打卡"冲突;暂按格子制实现、存档预留 capacity_type 字段 | 格子管理成为主要负面反馈 | pending_user(B 级阻断存档结构,暂按格子制) |
|
||||
|
||||
### 待原型验证(prototype_pending)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-13b | 战斗判定窗口手感(前摇帧数/无敌帧 450ms 基准) | 系统 | user | 数值可定、手感不可纸面验证 | 原型显示节奏拖慢/玩家困惑 | prototype_pending(规则本体见 D-13 confirmed) |
|
||||
|
||||
### 默认建议兜底(default_pending)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-19 | 天气权重表具体数值(晴/雨/风暴按季节) | TDD | agent | 概念层只定"雨免浇水"定性;数值推内容期填 | 前 5 日出现连续 3 日雨/全无雨 | default_pending(默认值已进数据表,带 designer_note) |
|
||||
|
||||
## 已采用决定
|
||||
|
||||
### 用户确认(confirmed)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-01 | 定调:牧场物语系参照、治愈慢节奏 | 概念 | user | 用户原始需求 | — | confirmed |
|
||||
| D-02 | 单人体验,无多人 | 概念 | user | 概念层非目标 | — | confirmed |
|
||||
| D-03 | 战斗保持伴生风险,不做装备驱动主轴 | 概念 | user | 概念期问题一 | 矿井流失率过半且归因战斗 | confirmed |
|
||||
| D-07 | 日目标自设,季节与社区提供低频牵引 | 顶层 | user | 顶层期问题一 | 新手周流失归因无方向 | confirmed |
|
||||
| D-11 | 采集/钓鱼/战斗统一"活动结果"接口 | 架构 | user | 架构期问题一 | 第三活动类型出现结构性差异 | confirmed |
|
||||
| D-13 | 战斗采用节奏/指令判定 | 系统 | user | S06 问题一 | 原型显示节奏拖慢/玩家困惑 | confirmed(手感部分拆 D-13b prototype_pending) |
|
||||
|
||||
### 技术代决(auto_decided——带理由与推翻条件,用户一键可翻)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据(理由) | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-04 | 时间片制:700ms=10 游戏分钟 | 概念 | agent | 原作实证节拍;一天≈14 分钟真实时间贴合 T5"休闲" | 内测一天体感过短/过长 | auto_decided |
|
||||
| D-05 | 分区域切换(区域独立场景,非连续地图) | 概念 | agent | 概念层"不是什么:无边界开放世界";区域小网络全部步行可达 | 场景切换成为移动负担反馈 | auto_decided |
|
||||
| D-06 | 28 日/季、四季/年 | 顶层 | agent | 季节窗口制造"本季计划"节奏(支柱二) | 换季频率在测试中被无视 | auto_decided |
|
||||
| D-09 | 商店营业时段走条件表 | 架构 | agent | 与配方/区域解锁共用 check(condition_id) 单一入口 | 条件表规模膨胀难维护 | auto_decided |
|
||||
| D-10 | 出货箱日终统一结算 | 架构 | agent | 收入集中进日终面板,强化"一天一结算"叙事;商店现卖保留即时通道 | 玩家普遍绕开出货箱 | auto_decided |
|
||||
| D-12 | 工具升级期间该工具不可用 | 系统 | agent | 升级=时间成本换效率(顶层张力二);备用旧工具暂不做(开放问题) | 升级期挫败感集中爆发 | auto_decided |
|
||||
| D-16 | 矿井逐层生成本期不做(P2) | TDD | agent | GDD 已标"不做无限地牢";首期按布局池 8~12 模板拼装 | 内测要求深度爬塔玩法 | auto_decided |
|
||||
| D-17 | 换装首期 5 层(基础体/裤/衣/发型/饰件),非 19 层 | TDD | agent | 外观自定义非首期卖点;层结构预留到 19 层 | 外观系统成核心诉求 | auto_decided |
|
||||
| D-18 | 作物品质三档:普通/银/金 | TDD | agent | 经济分层需要(即时变现 vs 等待升值的取舍) | 银金档无人区分、一律普通出售 | auto_decided |
|
||||
|
||||
### 已推翻(overturned——旧行保留,挂新行)
|
||||
|
||||
| 编号 | 决定 | 层 | 谁 | 依据 | 推翻条件 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-08 | 作物品质两档:普通/银 | TDD | agent | 早期小权衡:两档最简 | — | **overturned → D-18**(经济分层不足,改三档;铱档留 P1) |
|
||||
|
||||
## 队列纪律(使用说明)
|
||||
|
||||
- 新决定入队:拿下一号(当前最大 D-19,下一号 D-20);就现代决可登记不开条目,但状态必须写 auto_decided 并带理由+推翻条件。
|
||||
- 用户翻案:旧行标 overturned 挂新行,受影响文档节重写(本台账只记录,不代改)。
|
||||
- 概念层变更定稿后:速览卡"决定状态与原型验证项"字段随本文件最新版同步。
|
||||
- 体力与战斗是否共享资源:涉及探索压力和恢复规则,结合用户对战斗体验的要求确认,再更新相关系统设计。
|
||||
- 背包使用格子还是重量限制:在确定存档和界面结构前确认,选择依据记录在分析文档。
|
||||
- 战斗判定窗口的手感:通过矿井原型试玩观察是否易懂、是否拖慢探索,依据见分析文档“战斗判定窗口是否需要调整”。
|
||||
- 天气权重是否合适:用前几天的游玩样本检查连续雨天或长期无雨的影响,再调整配置。
|
||||
|
||||
+24
-25
@@ -1,59 +1,58 @@
|
||||
# 速览卡:《星露谷物语》金样项目
|
||||
|
||||
> 字段来源见每节尾注(概念层/定调记录/决策台账)。概念层变更定稿后本卡必须同步更新。
|
||||
> 本卡概括当前游戏;仅在核心体验、范围、平台等概览内容变化时更新。具体规则和取舍依据见对应设计与分析文档。
|
||||
|
||||
## 1. 游戏名称
|
||||
《星露谷物语》(金样项目沿用案例名;新项目由概念层第 1 节定名)←概念层§1
|
||||
《星露谷物语》(金样项目沿用案例名)←概念设计标题
|
||||
|
||||
## 2. 一句话描述
|
||||
继承一座荒废农场的乡村生活 RPG:安排每天的时间与体力,种田、探索、交朋友,把日子过成自己想要的样子。(←概念层§1 一句话概念,45~90 字)
|
||||
继承一座荒废农场的乡村生活 RPG:安排每天的时间与体力,种田、探索、交朋友,把日子过成自己想要的样子。(←概念层「游戏概念」)
|
||||
|
||||
## 3. 游戏分类
|
||||
乡村生活模拟 RPG(经营+探索+社交;参照系牧场物语)←概念层§1
|
||||
乡村生活模拟 RPG(经营+探索+社交;参照系牧场物语)←概念层「游戏概念」
|
||||
|
||||
## 4. 美术风格(四件)←定调记录+概念层§4
|
||||
## 4. 美术风格(四件)←概念层「身份、基调与世界观」与美术圣经
|
||||
- 视觉类型:手绘感像素风、俯视 45° 视角。
|
||||
- 风格关键词:温暖、田园、四季分明、生活感。
|
||||
- 色彩与氛围:暖土绿基底+季节信号色整体切换;治愈不压抑;无锐利科技感、无阴暗元素。
|
||||
- MVP 美术边界:首期 3 区域 tileset、8 位 NPC(行走+立绘)、约 120 物品图标、玩家换装 5 层;不做 19 层全量换装与全区域。
|
||||
|
||||
## 5. 游戏支柱(3 条)←设计锚点提炼
|
||||
## 5. 游戏支柱(3 条)←概念层「游戏概念」「体验与玩法」
|
||||
| 支柱 | 玩家感受 | 实现机制 |
|
||||
|---|---|---|
|
||||
| 自己的节奏 | "今天想干嘛就干嘛,明天一切更顺手。" | 自由日程+时间体力预算;无失败结局 |
|
||||
| 今天的选择让明天更从容 | "升级工具、攒钱扩建是有意义的。" | 长期投资线:工具升级/技能/设施 |
|
||||
| 社区让独居变成归属 | "镇上的人在等我。" | NPC 关系/任务/社区修复目标 |
|
||||
|
||||
## 6. 核心循环(5 步)←锚点循环位展开
|
||||
## 6. 核心循环(5 步)←概念层「体验与玩法」
|
||||
安排一天的时间与体力 → 农/采/钓/矿/战/社交任选组合 → 获得资源·金钱·经验·关系 → 投资工具·设施·种子·物品 → 解锁更高效或更丰富的活动。
|
||||
|
||||
## 7. 目标用户 ←概念层§5
|
||||
## 7. 目标用户 ←概念层「目标玩家与情境」
|
||||
牧场物语系慢节奏成长玩家+动森式"无压力日常"需求;单人、可反复、每次一至数个游戏日;不要求预先掌握复杂数值。
|
||||
|
||||
## 8. 平台事实(禁改)
|
||||
Web 浏览器运行 · 双视口(桌面/移动)· 键鼠/触屏双输入 · 本地启动后可在浏览器中试玩。
|
||||
|
||||
## 9. MVP 系统(5 个)←概念层"最小闭环粗清单"
|
||||
## 9. MVP 系统(本例首期范围)
|
||||
| 系统 | 最小功能 | 为什么必须有 | 验证方法 |
|
||||
|---|---|---|---|
|
||||
| 时间与日程 | 时钟/日终结算/季节天气 | 全局节拍器 | 一个游戏日全流程可完成并结算 |
|
||||
| 体力与状态 | 单池体力/昏倒惩罚 | 一切取舍的成本源 | 玩家主动在体力耗尽前收手 |
|
||||
| 农场经营 | 锄种浇收+加工队列 | 核心产出与规划场 | "买种→收获→出售"闭环成立 |
|
||||
| 物品与制作 | item_id/背包/配方解锁 | 资源身份与转化 | 拾取/堆叠/制作全链无回翻 GDD |
|
||||
| 经济与商店 | 基准价+价差+出货箱 | 投资回报换算 | 第 4 日现金流回正(前五日验算) |
|
||||
| 时间与日程 | 时钟、天气、日终协调与跨日推进 | 组织日常活动 | 单日行动与多日状态持续一致 |
|
||||
| 体力与状态 | 行动成本、休息恢复 | 支持日常计划与取舍 | 结合行动调整和玩家反馈判断压力 |
|
||||
| 农场经营 | 耕种、浇水、生长与收获 | 核心产出与规划场 | 连续数日完成生长、收获与再投资 |
|
||||
| 探索与地图 | 农场、小镇与基础采集区域 | 支持外出和活动选择 | 移动、出入口与资源点状态正确 |
|
||||
| 采集与钓鱼 | 本期基础采集,钓鱼后续加入 | 提供农场外的资源来源 | 采集结果正确入账且不重复领取 |
|
||||
| 物品与制作 | 本期物品身份、背包与工具使用 | 连接活动成果与投资 | 拾取、消耗及存读档结果一致 |
|
||||
| 成长与技能 | 基础农务或采集成长 | 为后续活动提供目标 | 多日成果产生可理解的能力变化 |
|
||||
| 经济与商店 | 买种、出售与基础投资 | 连接产出与后续投入 | 收益可用于下一轮活动,具体节奏待验算与试玩 |
|
||||
|
||||
## 10. 制作边界 ←概念层"不是什么"表
|
||||
## 10. 制作边界 ←概念层「边界与约束」
|
||||
不做:硬核生存(无饥饿/债务/死亡惩罚);效率至上的工厂经营;以战斗为核心的动作游戏;剧情驱动的任务链主线;多人竞争;无边界开放世界(区域小网络全步行可达)。
|
||||
|
||||
## 11. 创作者提示(先做与验证)←概念层"先做与验证"节
|
||||
- 先做:第 1 日循环(买种→播种→浇灌→收获→出售→日终结算)+一个可进入的矿井遭遇。
|
||||
## 11. 创作者提示(本例原型验证安排)
|
||||
- 先做:单日农务与基础采集,继续数日覆盖作物生长、收获、出售、投资和基础成长,包含必要的 UI 与存读档。
|
||||
- 暂不做:装备刷取、随机构筑、复杂剧情、节日全量、联机。
|
||||
- 这样验证:测试者玩完第 1 日后是否主动说"再玩一天";能否说出"明天要先做什么"。
|
||||
- 达标再扩展:玩家能自述明日计划后,才加社交深度与矿井分层。
|
||||
- 这样验证:结合试玩观察和玩家对选择理由、后续目标的说明,判断是否形成有意义的计划;同时检查资源与跨日状态的一致性。
|
||||
- 后续验证:加入一个矿井遭遇,再逐步覆盖关系与社区目标;依据实际问题调整范围,不把能自述计划作为唯一门槛。
|
||||
|
||||
## 12. 决定状态与原型验证项(依据决策台账)
|
||||
- 已确认(confirmed):定调 D-01 / 单人 D-02 / 战斗伴生 D-03 / 日目标自设 D-07 / 活动统一接口 D-11 / 战斗节奏判定 D-13。
|
||||
- 技术代决(auto_decided,可一键翻案):D-04 时间片 / D-05 分区域切换 / D-06 28 日季 / D-09 营业条件 / D-10 出货箱日终 / D-12 工具占用 / D-16 矿井生成 P2 / D-17 换装 5 层 / D-18 品质三档(推翻 D-08 两档)。
|
||||
- 等拍板(pending_user):D-14 体力战斗是否共享单池(暂按共享实现);D-15 背包格子/重量(暂按格子制,B 级阻断存档结构)。
|
||||
- 待原型(prototype_pending):D-13b 战斗判定窗口手感(前摇帧数/450ms 无敌帧基准)。
|
||||
- 默认兜底(default_pending):D-19 天气权重数值(默认已入表,带 designer_note)。
|
||||
## 12. 待原型验证项
|
||||
- 矿井中的轻度战斗能否提供节奏变化,同时保持探索流畅;通过原型试玩观察判定是否易懂、战斗是否拖慢探索。
|
||||
|
||||
+73
-179
@@ -1,200 +1,94 @@
|
||||
# 系统架构:《星露谷物语》
|
||||
|
||||
## 架构定位与目标
|
||||
本阶段确定"哪些系统支撑一轮玩法",不展开单系统内部规则。
|
||||
划分原则:将生活模拟 RPG 拆成职责清晰、可独立讨论的规则系统,同时保留少量跨系统入口,避免"每个功能都能互相调用"造成架构失控。系统划分服务于顶层循环:安排一天、执行活动、获得进展、投入成长、解锁新选择。
|
||||
## 系统与职责
|
||||
|
||||
一句话架构:
|
||||
> 玩家在有限的时间与体力下,通过农场、探索与社交三组活动系统产出资源与关系,经物品与经济系统转化为投资,由时间系统推进日终,把一天的成果变成下一天的选择。
|
||||
架构承接顶层的农场生活体验:安排一天、执行活动、获得进展、投入成长,再形成后续计划。战斗服务于探索中的风险与节奏变化,不作为装备成长主轴。以下系统覆盖完整版本,首个原型只实现其中必要的能力。
|
||||
|
||||
变更记录:
|
||||
- 2026-09-05:战斗与敌人系统定为伴生风险定位,深度刻意受限,不进入最小闭环核心链(依据:概念分析 D-03)。
|
||||
|
||||
## 系统地图
|
||||
|
||||
| 编号 | 系统 | 一句话职责 | 优先级 |
|
||||
| 编号 | 系统 | 职责与权威维护的状态 | 首个原型范围 |
|
||||
|---|---|---|---|
|
||||
| S01 | 时间与日程 | 推进游戏时间、日期、季节、天气、营业时间、NPC 日程和日终结算 | P0 |
|
||||
| S02 | 体力与状态 | 管理体力、负面状态、恢复、昏倒和行动成本 | P0 |
|
||||
| S03 | 农场经营 | 管理土地、作物、畜牧、农场设施和生产状态 | P0 |
|
||||
| S04 | 探索与地图 | 管理区域、出入口、可交互资源点、地图解锁和移动 | P0(基础) |
|
||||
| S05 | 采集与钓鱼 | 管理野外采集、钓鱼活动、资源品质和获得物 | P1 |
|
||||
| S06 | 战斗与敌人 | 管理矿区或危险区域中的战斗、伤害、敌人行为和战利品 | P1 |
|
||||
| S07 | 物品、背包与制作 | 管理物品实例、堆叠、工具、装备、配方和制作队列 | P0 |
|
||||
| S08 | 成长与技能 | 管理技能经验、等级、工具升级、职业选择和能力解锁 | P0(基础) |
|
||||
| S09 | 经济与商店 | 管理货币、买卖、价格、商店库存、订单和资金流 | P0 |
|
||||
| S10 | NPC 与关系 | 管理 NPC 日程、对话、好感度、礼物偏好和关系事件 | P1 |
|
||||
| S11 | 任务与社区目标 | 管理任务状态、阶段目标、奖励、社区修复和区域解锁条件 | P1 |
|
||||
| S12 | 事件与节日 | 管理季节事件、节日活动、条件触发和特殊奖励 | P1 |
|
||||
| S01 | 时间与日程 | 时钟、日期、季节、天气,时间通知与日终流程协调 | 基础时间、天气与跨日推进 |
|
||||
| S02 | 体力与状态 | 体力、恢复、昏倒和状态效果,处理活动提交的成本 | 农务与采集的行动成本、休息恢复 |
|
||||
| S03 | 农场经营 | 土地、作物、畜牧、设施生产状态与生产规则 | 耕种、浇水、生长与收获 |
|
||||
| S04 | 探索与地图 | 区域、出入口、角色位置、资源点位置与可用状态,执行移动和区域开放 | 农场、小镇与基础采集区域 |
|
||||
| S05 | 采集与钓鱼 | 活动判定、获得物与品质规则 | 基础采集;钓鱼后续加入 |
|
||||
| S06 | 战斗与敌人 | 战斗过程、敌人状态、伤害和战利品请求 | 后续矿井遭遇原型 |
|
||||
| S07 | 物品、背包与制作 | 物品身份、实例、容器、配方、工具装备及通用制作队列 | 种子、工具、采集物和农产品的持有与使用 |
|
||||
| S08 | 成长与技能 | 经验、等级、能力与配方解锁条件 | 基础农务或采集成长 |
|
||||
| S09 | 经济与商店 | 货币、价格、交易、库存及营业条件 | 买种、出售与基础投资 |
|
||||
| S10 | NPC 与关系 | NPC 日程内容与执行进度、对话、好感和关系事件 | 后续关系原型 |
|
||||
| S11 | 任务与社区目标 | 任务状态、奖励、社区进度和区域解锁条件 | 后续社区目标原型 |
|
||||
| S12 | 事件与节日 | 节日内容、触发条件、活动流程与完成状态 | 后续节日内容 |
|
||||
|
||||
支撑层(不拥有核心规则):
|
||||
- 存档与进度系统:保存跨日、跨季节和跨阶段的持久状态。
|
||||
- UI 与文本呈现层:展示状态、提供操作入口、呈现反馈与文本。
|
||||
存档保存各系统的持久状态并按归属恢复;UI 与文本呈现展示结果、提供操作入口。它们需要实现规格,但不另行维护玩法规则,规格在 TDD 中展开。
|
||||
|
||||
P0 段:
|
||||
容易混淆的边界:
|
||||
|
||||
| 系统 | 目的 | 输入 | 输出 | P0 原因 |
|
||||
|---|---|---|---|---|
|
||||
| S01 时间与日程 | 全局时钟与日终 | 各系统行动完成信号、日终触发 | 日期/季节/天气变化、日终结算、跨天 tick | 没有"一天",规划与取舍失去标尺 |
|
||||
| S02 体力与状态 | 全局行动成本 | 各系统行动请求、食物与休息 | 体力变化、昏倒、状态效果 | 没有它,"想做的事多于做得到的"不成立 |
|
||||
| S03 农场经营 | 核心产出与规划场 | 时间 tick、种子与工具、体力 | 作物畜产品、设施生产状态 | 概念核心承诺的载体 |
|
||||
| S04 探索与地图 | 活动场景与空间约束 | 移动指令、区域解锁条件 | 位置、区域状态、资源点入口 | 没有空间结构,农/矿/镇一体失去意义 |
|
||||
| S07 物品与制作 | 资源身份与转化 | 各系统获得物、配方请求 | 物品实例、制作结果 | 所有系统产出的公共语言 |
|
||||
| S08 成长与技能 | 长期回报层 | 各活动经验提交 | 等级、能力与配方解锁 | 长期动机的最小载体 |
|
||||
| S09 经济与商店 | 投资与回报换算 | 物品、金钱 | 价格、交易、库存 | 没有它,"变现 vs 投资"张力无载体 |
|
||||
- S01 提供时钟与日期,S10 根据自身日程决定 NPC 的目标和行动,通过 S04 执行移动;S09 判断商店是否营业。时间系统不维护另一套居民日程或商店规则。
|
||||
- S04 维护资源点的位置和是否仍可采集,S05 判定本次采集的结果;物品入账由 S07 处理,经验由 S08 处理。
|
||||
- S03 管理农场设施的生产状态,S07 管理背包与通用制作。共用配方时引用同一配方定义,不各自复制材料与产出规则。
|
||||
- S11 判断社区目标是否满足解锁条件,S04 维护实际开放的区域;S08 管理技能解锁,S07 据此判断配方或工具能否使用。
|
||||
|
||||
## 系统职责
|
||||
## 协作与数据归属
|
||||
|
||||
| 系统 | 主要职责 | 不负责 → 移交谁 |
|
||||
|---|---|---|
|
||||
| S01 时间与日程 | 时间推进、日期、季节、天气、营业与日终 | 直接决定某项活动的奖励 → 各活动系统 |
|
||||
| S02 体力与状态 | 行动消耗、恢复、昏倒、状态效果 | 农作物或敌人的具体配置 → S03/S06 |
|
||||
| S03 农场经营 | 土地、作物、畜牧、设施生产 | 商店买卖规则和角色技能 → S09/S08 |
|
||||
| S04 探索与地图 | 区域连接、进入条件、资源点位置、移动 | 具体掉落概率和战斗公式 → S05/S06 |
|
||||
| S05 采集与钓鱼 | 采集和钓鱼行为、成功条件、获得物 | 物品价格和任务奖励 → S09/S11 |
|
||||
| S06 战斗与敌人 | 战斗流程、敌人状态、伤害与战利品请求 | 角色长期成长和商店价格 → S08/S09 |
|
||||
| S07 物品与制作 | 背包、物品、配方、制作与工具装备 | 物品最终经济价值的平衡目标 → S09 |
|
||||
| S08 成长与技能 | 经验、等级、技能分支、能力解锁 | 单次行动的基础奖励 → 各活动系统 |
|
||||
| S09 经济与商店 | 货币、交易、库存、订单、价格 | 任务剧情与 NPC 情感变化 → S10/S11 |
|
||||
| S10 NPC 与关系 | 日程、互动、好感、关系事件 | 全局季节推进和商店库存 → S01/S09 |
|
||||
| S11 任务与社区 | 目标、前置、奖励、社区进度和解锁 | NPC 的日常行为表现 → S10 |
|
||||
| S12 事件与节日 | 周期事件、特殊流程和限定内容 | 常规日常行动的基础规则 → 各活动系统 |
|
||||
以下表格描述行动处理和通知,不把所有关系混成同一种依赖箭头。
|
||||
|
||||
职责说明:
|
||||
|
||||
### S01 时间与日程系统
|
||||
负责一天制的节奏规则:什么时候推进日期、哪些系统收到跨天 tick、日终结算何时发生。它不负责奖励结算,也不负责作物成长规则——只负责"什么时候"和"谁被通知"。
|
||||
|
||||
### S06 战斗与敌人系统
|
||||
负责战斗内状态、敌人行为与战利品请求。它不直接修改商店价格或 NPC 好感,不负责角色长期成长;战利品只提交请求,由 S07 物品系统入账。定位是矿井探索的风险与节奏变化,不是成长主轴(D-03)。
|
||||
|
||||
### S07 物品、背包与制作系统
|
||||
负责物品身份、容器、堆叠与配方队列。它是全项目的公共语言层——任何系统的产出都以 `item_id` 入账;它不负责物品的经济价值平衡,价格只由 S09 维护。
|
||||
|
||||
## 依赖与数据流
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1[S01 时间与日程] --> S2[S02 体力与状态]
|
||||
S1 --> S3[S03 农场经营]
|
||||
S1 --> S4[S04 探索与地图]
|
||||
S1 --> S10[S10 NPC 与关系]
|
||||
S1 --> S12[S12 事件与节日]
|
||||
S3 --> S7[S07 物品与制作]
|
||||
S4 --> S5[S05 采集与钓鱼]
|
||||
S4 --> S6[S06 战斗与敌人]
|
||||
S5 --> S7
|
||||
S6 --> S7
|
||||
S7 --> S9[S09 经济与商店]
|
||||
S7 --> S8[S08 成长与技能]
|
||||
S8 --> S7
|
||||
S10 --> S11[S11 任务与社区]
|
||||
S12 -.读取日期季节.-> S1
|
||||
UI[UI 与文本呈现] -.读取状态.-> S1
|
||||
UI -.读取状态.-> S3
|
||||
UI -.读取状态.-> S7
|
||||
SAVE[存档与进度] -.订阅持久状态.-> S1
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T[时间/体力] --> ACT[玩家行动]
|
||||
ACT --> GAIN[物品·金钱·经验·关系·任务进度]
|
||||
GAIN --> INV[制作·交易·升级·解锁]
|
||||
INV --> NEW[新的行动选择]
|
||||
NEW --> ACT
|
||||
```
|
||||
|
||||
主要状态:
|
||||
- 全局状态:日期、季节、天气、当前时间、已解锁区域、社区进度。
|
||||
- 玩家状态:位置、体力、生命、技能等级、工具、装备、背包和金钱。
|
||||
- 场景状态:土地、作物成长、设施生产、资源点、敌人和宝箱。
|
||||
- 社会状态:NPC 位置、关系值、已触发事件、任务阶段和节日参与状态。
|
||||
|
||||
主数据归属规则:
|
||||
- 规则文档描述"如何计算"和"何时发生";数据表描述"有哪些对象"和"每个对象的配置"。
|
||||
- 系统之间通过稳定 ID 关联(物品 ID、NPC ID、区域 ID、任务 ID、配方 ID)。
|
||||
- 任何系统都不复制另一系统的主数据;任务只引用物品 ID,不重新定义物品价格。
|
||||
|
||||
## 核心循环覆盖检查
|
||||
|
||||
| 顶层循环环节 | 认领系统 |
|
||||
| 行动或时机 | 协作与结果归属 |
|
||||
|---|---|
|
||||
| 查看天气、日程与目标 | S01、S11、S12、UI |
|
||||
| 选择活动并移动 | S04、S02 |
|
||||
| 农务与生产 | S03、S07、S02 |
|
||||
| 采集、钓鱼与战斗 | S04、S05、S06、S07 |
|
||||
| 出售、购买与投资 | S09、S07、S03 |
|
||||
| 社交与委托 | S10、S11、S07 |
|
||||
| 日终结算与保存 | S01、S12、存档、UI |
|
||||
| 播种与农务 | S03 检查地块及行动条件,S07 检查种子或工具,S02 检查行动成本;确认可执行后更新各自状态。失败时不留下仅扣种子或体力的部分结果 |
|
||||
| 采集 | S04 确认资源点可用,S05 判定获得物,S07 入账,S08 接收活动经验;成功后由 S04 更新资源点状态 |
|
||||
| 出售与购买 | S09 校验营业、库存与价格,S07 校验物品和容器;交易成功时双方分别更新所拥有的状态,失败时保持原状态 |
|
||||
| 成长与解锁 | 活动系统报告成果,S08 更新经验与能力;S07 等使用方读取解锁结果,不自行维护另一套技能进度 |
|
||||
| 日终 | S01 停止当日行动并协调结算;S03 推进作物与生产、S09 结算出货、S08 结算成长,之后汇总反馈并保存。跨日通知让各系统准备次日状态 |
|
||||
| 居民行动与节日 | S10、S12 读取 S01 的日期与时间,按各自规则决定活动;需要移动时交给 S04,不反过来推进全局时钟 |
|
||||
|
||||
## 目录映射
|
||||
跨系统行动的提交方式、失败处理和精确结算顺序在系统文档与 TDD 中展开,须满足上述结果一致性。日终保存应包含已经完成的结算,不能读档后重复发放同一次收益。
|
||||
|
||||
| 目录 | 本阶段定位 |
|
||||
系统通过 `item_id`、`npc_id`、`region_id`、`recipe_id` 等稳定标识关联。物品身份与实例归 S07,价格归 S09,关系值归 S10;任务、界面和存档可以引用或展示这些结果,不独立修改对应事实。UI 从权威状态刷新,只读展示副本不承担结算;存档快照在结算完成后生成,读档时恢复到对应系统。
|
||||
|
||||
跨系统共享的约束:
|
||||
|
||||
- 时间与生产使用一致的游戏时间单位,行动成本、制作时长与跨日成长须说明对应关系。顶层暂定常规游戏日约 10~20 分钟,实际换算与暂停规则在后续规格中明确并试玩验证。
|
||||
- 金钱由 S09 统一结算,各活动提供产物或交易请求;经验是持续积累的进展,不作为货币消费。
|
||||
- 失败后果按顶层场景分别处理:矿井倒下可能损失部分钱物,换季可能使作物枯萎,同时保留大部分长期进展。涉及体力、物品、金钱或位置的变化由各自负责系统执行。
|
||||
- 存档、UI 和活动系统使用相同的状态含义,避免显示已获得但实际未入账、或已结算却未保存的结果。
|
||||
|
||||
## 系统文档映射
|
||||
|
||||
本例分别展开各系统,以下是策划工作区的文档目录;实现代码如何拆模块由 TDD 决定。
|
||||
|
||||
| 系统 | 文档目录 |
|
||||
|---|---|
|
||||
| 03_systems/S01_time_schedule/ | 时间推进、日期季节天气、营业时段、日终结算 |
|
||||
| 03_systems/S02_stamina_status/ | 体力、状态效果、昏倒与恢复 |
|
||||
| 03_systems/S03_farm_management/ | 土地、作物、畜牧、设施生产 |
|
||||
| 03_systems/S04_exploration_map/ | 区域、连接、资源点、解锁与移动 |
|
||||
| 03_systems/S05_foraging_fishing/ | 采集、钓鱼、品质与获得物 |
|
||||
| 03_systems/S06_combat_enemies/ | 战斗、敌人行为、伤害与战利品请求 |
|
||||
| 03_systems/S07_items_inventory_crafting/ | 物品、背包、配方与制作队列 |
|
||||
| 03_systems/S08_progression_skills/ | 技能经验、等级、工具升级、能力解锁 |
|
||||
| 03_systems/S09_economy_shop/ | 货币、买卖、价格、库存与订单 |
|
||||
| 03_systems/S10_npc_relationship/ | NPC 日程、对话、好感与关系事件 |
|
||||
| 03_systems/S11_quests_community/ | 任务、社区目标、奖励与解锁条件 |
|
||||
| 03_systems/S12_events_festivals/ | 季节事件、节日、条件触发 |
|
||||
| 支撑层不单开系统文档 | 存档与 UI 随实现层组织,规则不独立成文 |
|
||||
| S01 时间与日程 | `project/03_systems/S01_time_schedule/` |
|
||||
| S02 体力与状态 | `project/03_systems/S02_stamina_status/` |
|
||||
| S03 农场经营 | `project/03_systems/S03_farm_management/` |
|
||||
| S04 探索与地图 | `project/03_systems/S04_exploration_map/` |
|
||||
| S05 采集与钓鱼 | `project/03_systems/S05_foraging_fishing/` |
|
||||
| S06 战斗与敌人 | `project/03_systems/S06_combat_enemies/` |
|
||||
| S07 物品、背包与制作 | `project/03_systems/S07_items_inventory_crafting/` |
|
||||
| S08 成长与技能 | `project/03_systems/S08_progression_skills/` |
|
||||
| S09 经济与商店 | `project/03_systems/S09_economy_shop/` |
|
||||
| S10 NPC 与关系 | `project/03_systems/S10_npc_relationship/` |
|
||||
| S11 任务与社区目标 | `project/03_systems/S11_quests_community/` |
|
||||
| S12 事件与节日 | `project/03_systems/S12_events_festivals/` |
|
||||
|
||||
## MVP 最小闭环
|
||||
1. 玩家在一个游戏日内完成开垦、播种、浇灌,并看到成长状态反馈。
|
||||
2. 在时间与体力约束下选择当日主目标(农场劳动或外出)。
|
||||
3. 外出采集(或矿井轻度战斗)带回资源。
|
||||
4. 通过出售或加工获得金钱,投资种子或工具。
|
||||
5. 日终结算展示当日变化并保存。
|
||||
6. 次日作物状态变化,玩家据此形成新计划。
|
||||
7. 数个游戏日内出现第一次技能提升与配方解锁。
|
||||
## 实现范围与验证
|
||||
|
||||
如果这条闭环不成立,不应继续增加钓鱼深度、节日、社区目标或更多区域。
|
||||
首个原型包含 S01、S02、S03、S04、S05、S07、S08、S09 的上述基础能力,加上必要的 UI 与存读档。完整版本还需展开钓鱼、制作、畜牧、战斗、居民关系、社区目标与节日等能力;完整清单不等于首个原型的施工范围。
|
||||
|
||||
## 统一数值基准
|
||||
本案例采用"宽松治愈型"数值风格。全局单位:时间片、游戏日、货币、体力、经验;所有数值字段必须注明单位。
|
||||
- 时间节奏基准:单次常规行动控制在短时间片内;玩家一天应能完成农务、一个主要外出目标和少量顺路活动;早期玩家不应因一次路线失误失去整天进度。
|
||||
- 货币量级基准:主要货币只有一种;初期基础种子可用少量日常产出购买;一次普通收获不应立刻买下最高阶升级;任务奖励以补足短期资金为主,不替代生产交易。
|
||||
- 成长回报基准:前几级在正常尝试一种活动的数个游戏日内出现;升级奖励优先采用节省时间体力、扩大选择和解锁配方,而非单纯提高伤害售价;专长分支宽松可恢复。
|
||||
- 体力与风险基准:体力是规划提示不是严苛倒计时;普通农务与移动成本低,战斗、钓鱼和重型工具才产生明显取舍;失败成本采用时间、少量金钱或位置变化,不损毁进度。
|
||||
首个流程从查看天气和选择目标开始,经农务或外出采集获得进展,再通过出售、购买和日终进入下一天。单日用于观察计划与取舍;连续数日用于覆盖作物生长、收获、投资和基础成长,不要求作物一天内完成播种到收获。
|
||||
|
||||
(具体换算数值与前五日验算由技术文档层·数值策划承接。)
|
||||
|
||||
## 系统边界
|
||||
- 农场经营只管理农场内的生产状态,不负责所有资源的通用背包逻辑。
|
||||
- 探索与地图只管理"在哪里"和"能否进入",不管理每种活动的具体奖励。
|
||||
- 战斗只管理战斗内状态和战利品请求,不直接修改商店价格或 NPC 好感。
|
||||
- NPC 与关系负责互动和关系变化;任务与社区负责可验证目标,二者通过事件和条件连接。
|
||||
- UI、文本和表现不反向承载核心规则;所有关键变化必须由规则系统确认。
|
||||
- 本案例不拆出独立多人、拍卖、复杂天气模拟、动态市场或高复杂度叙事工具系统。
|
||||
|
||||
## 优先级与范围
|
||||
- P0(最小可玩闭环):时间与日程、体力、农场、物品背包、经济、基础地图、基础成长和日终结算。
|
||||
- P1(形成完整案例):采集、钓鱼、轻度战斗、NPC 关系、任务、社区目标、制作、商店、季节和节日。
|
||||
- P2(扩展内容):更多区域、敌人、作物、配方、关系事件、节日小游戏和终局后的自由活动。
|
||||
|
||||
拆分系统不等于所有系统都要在最小版本同时实现;系统独立性是为了便于协作和后续裁剪。
|
||||
|
||||
## 风险与校验
|
||||
|
||||
| 风险 | 校验方式 |
|
||||
| 验证问题 | 内容与判断依据 |
|
||||
|---|---|
|
||||
| 农场变成例行公事,失去规划感 | 玩家是否在目标选择阶段出现真实取舍与计划调整 |
|
||||
| 矿井战斗反客为主 | 战斗收益是否仍以"农场难以产出的材料"为主,而非直接金钱 |
|
||||
| 时间压力变成打卡义务 | 休闲型玩家能否自由调低日程重量而不被惩罚 |
|
||||
| 经济成长过快,后期失去决策 | 升级价格是否持续制造"效率 vs 规模"的选择 |
|
||||
| UI 泄题,探索失去意义 | 关键信息是否保留为探索发现而非全量直读 |
|
||||
| 系统间主数据重复维护 | 交叉检查:同一事实是否只有一个系统拥有写权 |
|
||||
| 基础系统是否共同支持日常计划 | 试玩农务与采集,观察时间、体力、物品和金钱变化是否一致,结合玩家说明判断选择是否有意义 |
|
||||
| 跨日成果能否支持后续计划 | 连续游玩并存读档,检查作物、交易与成长是否持续且无重复结算,结合玩家反馈判断是否形成新的目标 |
|
||||
| 战斗是否改善探索节奏 | 后续加入 S06 与矿井所需的地图、状态和物品能力,观察战斗理解、损失恢复及其对日常活动的影响 |
|
||||
| 关系与社区是否形成长期目标 | 后续加入 S10、S11 及对应内容,观察多日投入与目标选择;单日原型不据此判断长期体验 |
|
||||
|
||||
## 开放的结构问题
|
||||
- 体力与生命是否保持为两个状态,还是在轻度战斗中共享一套风险资源?
|
||||
- NPC 日程、任务条件和节日事件之间采用统一条件格式还是各自维护?
|
||||
- 农场设施生产是否由农场系统统一管理,还是交给通用制作队列?
|
||||
- 采集、钓鱼和战斗是否共享统一的"活动结果"接口?
|
||||
- 哪些系统需要独立数据表,哪些小型配置应合并为一张内容表?
|
||||
以上是验证计划,尚未形成试玩结论。出现问题时先判断是玩法目标不成立、协作职责遗漏还是实现错误,再调整相应设计与范围。
|
||||
|
||||
## 风险与未决问题
|
||||
|
||||
- 时间、体力和收益可能共同把休闲生活变成赶任务:沿用顶层验证计划,比较不同玩家的计划调整与压力反馈。
|
||||
- 农场生产、制作和交易可能重复消费或入账:在系统规格中明确提交与失败处理,在实现阶段验证中断和日终存读档后的结果。
|
||||
- 社区目标采用章节、可选收集还是组合仍需展开:在该内容进入实现范围前确定,并补齐 S11 与 S04 的解锁协作。
|
||||
- 体力与战斗生命是否共享、战斗最低深度如何确定:不阻塞基础日常原型,在战斗原型前解决,再补齐 S02、S06 及相关 TDD 规格。
|
||||
|
||||
+29
-49
@@ -1,63 +1,43 @@
|
||||
# 概念设计:《星露谷物语》
|
||||
|
||||
## 一句话概念
|
||||
《星露谷物语》是一款以经营农场为基础、融合探索、采集、制作、轻度战斗、角色成长与社区叙事的乡村生活模拟 RPG;玩家通过安排每日时间与体力,把荒废农场逐步建设成理想家园,并与周围居民建立关系。
|
||||
## 游戏概念
|
||||
|
||||
## 定调与设计锚点
|
||||
《星露谷物语》是一款以经营农场为基础、融合探索、采集、制作、轻度战斗、角色成长与社区叙事的乡村生活模拟 RPG。玩家安排每日时间与体力,把荒废农场逐步建设成理想家园,并与周围居民建立关系。吸引力在于按自己的节奏塑造生活:今天的选择让明天更从容,社区关系让独居逐渐变成归属。
|
||||
|
||||
### 定调记录
|
||||
- 参照选择:以牧场物语系为主(无压力日常方面学动物森友会);不参考任何高难动作与生存类游戏。
|
||||
- 调性滑杆:压力感 低 / 战斗比重 低 / 管理深度 中 / 叙事比重 中低 / 节奏 慢。
|
||||
- 调性锚:
|
||||
T1 轻松治愈、自己的节奏(目标体验);T2 不劝退、无唯一最优解(体验门槛);T3 战斗轻度、非高难动作(非目标);T4 以"游戏日"为单位、可反复的单人体验(情境);T5 时间体力有限但休闲不打卡(跑偏风险);T6 小团队可维护的规模(关键约束);T7 日常叙事而非宏大主线,隐藏信息不迫使玩家查攻略(非目标/跑偏风险)。
|
||||
## 体验与玩法
|
||||
|
||||
### 设计锚点
|
||||
- 核心幻想:离开令人疲惫的城市生活,继承一片荒废土地,在自己的节奏中经营、探索、成长,并成为社区的一员。
|
||||
玩家念头:"再玩一天就好——今天做完想做的事,明天的一切都会更顺手。"
|
||||
- 目标体验:治愈、自由规划、持续成长、发现秘密,以及"今天的选择会让未来更轻松"的掌控感。
|
||||
- 玩家动机:改善农场与生活条件;发现新区域和资源;完成社区目标;提升技能;与 NPC 建立关系;按照自己的偏好塑造生活方式。
|
||||
- 核心循环:安排一天的时间与体力 → 进行农业、采集、钓鱼、采矿、战斗或社交 → 获得资源、金钱、经验与关系进展 → 投资工具、设施、种子和物品 → 解锁更高效或更丰富的活动。
|
||||
- 跑偏风险:系统过多导致目标分散;时间与体力限制把休闲体验变成每日打卡;隐藏信息迫使玩家依赖外部攻略;经济成长过快使后期失去决策。
|
||||
- 非目标:不做多人竞争、高难度动作战斗、唯一最优效率经营、主线剧情取代日常(详见《不是什么》)。
|
||||
玩家通过改善农场、发现资源与区域、提升技能、完成社区目标和发展人际关系,获得自由规划、持续成长与发现秘密的乐趣。既可以追求效率,也可以把时间用于装饰、社交或探索。
|
||||
|
||||
## 玩家身份与基调
|
||||
- 玩家身份:一名辞职逃离城市、继承祖父荒废农场的归乡人——不是拯救世界的英雄,是重新学会生活的人。季节与节日构成一年的节拍,日落结算构成每天的呼吸。
|
||||
- 情绪基调:温暖治愈,慢而踏实。可以有忙碌与轻度压力(时间、体力),不做生存焦虑(饥饿、债务倒计时)与黑暗题材;孤独感只作为被社区逐渐治愈的起点,不成为基调本身。
|
||||
主要游玩过程是:安排一天的时间与体力 → 进行农业、采集、钓鱼、采矿、战斗或社交 → 获得资源、金钱、经验与关系进展 → 投资工具、设施、种子和物品 → 解锁更高效或更丰富的活动,在下一天重新安排计划。
|
||||
|
||||
## 风格与世界观
|
||||
复古像素风的温暖乡村世界。玩家来到一个正在现代化与传统生活之间摇摆的小镇,农场、商店、社区设施、自然区域和矿井共同构成可步行抵达的生活网络。世界观服务于生活模拟而非复杂设定解释:季节、天气、节日、居民日程和区域变化,让同一张地图随着时间产生生活感。叙事主要通过 NPC 日常对话、关系事件、任务和社区目标逐步展开。
|
||||
其中的重要取舍包括:
|
||||
|
||||
- 时间与体力有限,玩家需要安排今天的优先级,做一件事意味着少做另一些事。
|
||||
- 出售资源能立即获得资金,保留资源制作设备和升级工具则能提高未来效率。
|
||||
- 农场提供可预测的收益;探索可能带来新资源和发现,也会消耗时间、承担风险。
|
||||
- 赚钱占用社交时间;发展关系会减缓眼前收入增长,但能带来配方、剧情和情感回报。
|
||||
- 季节、节日和社区目标提供方向;追赶这些目标会占用自由安排日常的空间,错过部分机会则需要等待或调整计划。
|
||||
|
||||
## 身份、基调与世界观
|
||||
|
||||
玩家是一名辞职离开城市、继承祖父荒废农场的归乡人,在经营与探索中重新建立生活,并成为社区的一员。日落结算构成每天的节拍,季节与节日带来更长周期的变化。
|
||||
|
||||
情绪基调温暖治愈,节奏慢而踏实。时间与体力可以带来忙碌和轻度压力,但不以饥饿、债务倒计时或黑暗题材制造生存焦虑;孤独感是逐渐融入社区的起点。
|
||||
|
||||
视觉采用复古像素风的温暖乡村。农场、商店、社区设施、自然区域和矿井组成可步行抵达的生活网络。季节、天气、节日、居民日程和区域变化让同一张地图产生生活感;叙事通过日常对话、关系事件、任务和社区目标展开。
|
||||
|
||||
参照牧场物语系的农场生活组织方式,以及动物森友会的自定义、装饰和按自己节奏整理日常的体验;农业经营中的时间、体力、季节取舍与社区修复仍是本例的重要内容。参照用于说明体验,不复制具体角色、文本、美术、地图或数值。
|
||||
|
||||
## 目标玩家与情境
|
||||
- 目标玩家:与牧场物语系受众高度重合——喜欢种田与小人际的慢节奏成长玩家;同时吸收动物森友会式"无压力日常整理"的需求(自定义、装饰、按自己的节奏玩)。但它不能变成纯装饰沙盒,因为农场经营的时间、体力与季节取舍,以及社区修复目标必须始终存在。
|
||||
- 适合情境:单人、可反复游玩、每次一个游戏日或几个游戏日;可以高效规划,也可以把时间用于装饰、社交或探索。
|
||||
- 体验门槛:需要理解基础资源转换和时间安排,不应要求预先掌握复杂数值或寻找唯一正确答案。
|
||||
|
||||
## 不是什么
|
||||
| 不是 | 因为 |
|
||||
|---|---|
|
||||
| 硬核生存农场模拟 | 没有饥饿、债务、死亡惩罚;压力止于温和的时间与体力 |
|
||||
| 效率至上的工厂经营 | 不要求唯一最优解,装饰与闲逛是合法玩法而非浪费 |
|
||||
| 以战斗为核心的动作游戏 | 战斗只是采矿与探索的伴生风险,深度刻意受限 |
|
||||
| 剧情驱动的叙事游戏 | 社区叙事是日常的背景与情感回报,不是任务链主线 |
|
||||
| 多人社交平台 | 单人体验为前提,人际关系由 NPC 关系承载 |
|
||||
| 无边界开放世界 | 地图是功能明确的小区域网络,全部可步行抵达 |
|
||||
面向喜欢种田、人际关系与慢节奏成长的玩家,也容纳偏好装饰、收集和自由安排日常的玩家。
|
||||
|
||||
## 核心张力
|
||||
- 时间与体力有限,但想做的事情很多:玩家必须决定今天的优先级。
|
||||
- 立即变现与长期投资:出售资源能快速获得资金,制作设备和升级工具则能提高未来效率。
|
||||
- 稳定经营与未知探索:农场提供可预测收益,矿井、钓鱼和新区域提供风险与发现。
|
||||
- 个人效率与社区关系:把时间用于赚钱会挤压社交,但关系又会带来配方、剧情和新的情感目标。
|
||||
- 自由生活与阶段目标:玩家可以自由安排日常,同时受到季节、节日、任务和社区修复目标的轻度牵引。
|
||||
本例围绕可反复游玩的单人体验,每次可玩一个或几个游戏日。玩家需要理解基础资源转换和时间安排,不应依赖复杂数值计算或寻找唯一正确答案才能推进。
|
||||
|
||||
## 边界与约束
|
||||
- 概念层只定义核心幻想、目标用户、体验基调与排除方向;具体战斗公式、作物成长天数、礼物偏好、掉落率、系统清单和 MVP 内容,留给顶层及以后决定。
|
||||
- 设计规模以单人或小团队可理解、可维护为前提;地图采用多个功能明确的区域,而非无边界开放世界。
|
||||
- 所有系统都必须回流到"安排一天并获得长期改善"的核心循环;独立小游戏或装饰功能不能成为主要范围扩张来源。
|
||||
- 案例声明:本文以《星露谷物语》为案例展示设计的组织方式,不复制其具体角色、文本、美术、地图或数值。
|
||||
|
||||
## 概念定稿
|
||||
《星露谷物语》的核心不是"种田赚钱",而是:
|
||||
> 在自己的节奏里经营一片土地与一段生活——今天的选择让明天更从容,而社区让独居变成归属。
|
||||
|
||||
交给下一层的约束:时间与体力必须构成温和而非焦虑的取舍;战斗、采矿、社交等支线必须回流农场生活循环;成长权重要允许玩家自定义(效率型与休闲型玩家都成立)。
|
||||
(调性已在第 2 节定死;顶层及以下一切开放问题先回定调记录的 T1~T7 级联。)
|
||||
- 战斗服务于采矿与探索,不扩展为高难度动作游戏;社区叙事提供日常背景与情感回报,不用宏大主线取代农场生活。
|
||||
- 时间和体力形成温和的取舍,避免把休闲变成每日打卡。装饰、闲逛和社交都有价值,不以唯一最优效率为目标。
|
||||
- 本例按单人或小团队规模控制内容,地图采用功能明确的小区域网络,不扩展为无边界开放世界或多人社交平台。
|
||||
- 各系统服务于日常生活及其长期改善。独立小游戏或装饰内容的扩张不能挤占核心体验;同时避免经济成长过快使后期失去选择,或隐藏信息迫使玩家依赖攻略。
|
||||
- 后续设计应允许效率型和休闲型玩家按自己的偏好成长;具体系统范围、版本内容、战斗公式、作物成长天数和掉落率等再逐步展开。
|
||||
|
||||
+40
-104
@@ -1,120 +1,56 @@
|
||||
# 战斗与敌人系统:S06
|
||||
|
||||
## 系统目的
|
||||
为危险区域提供轻度、可理解的战斗挑战,使玩家在探索中承担风险,并通过装备、补给和技能成长验证长期准备。战斗是生活模拟循环的支柱之一,不是游戏的唯一核心。
|
||||
版本:v2
|
||||
|
||||
## 支撑的玩家体验
|
||||
- 玩家能观察敌人行为,选择攻击、躲避、补给或撤退。
|
||||
- 战斗结果主要取决于准备、判断和适度操作,而不是高强度连招。
|
||||
- 深入危险区域会带来更高资源和成长回报,也会增加生命、时间和补给压力。
|
||||
- 失败有明确原因和可恢复成本,不应摧毁长期农场进度。
|
||||
## 职责与原型范围
|
||||
|
||||
## 进入与退出
|
||||
### 进入
|
||||
- 玩家进入允许战斗的危险区域或触发敌人遭遇。
|
||||
- 检查区域、时间、装备、生命、背包和任务条件。
|
||||
- 初始化当前战斗区域、敌人组合、战斗状态和可撤退条件。
|
||||
### 退出
|
||||
- 击败敌人并完成战斗奖励结算。
|
||||
- 玩家主动撤退或离开战斗区域。
|
||||
- 玩家生命归零,由体力与状态系统执行昏倒或失败惩罚。
|
||||
- 特殊事件、日终或区域状态强制结束战斗。
|
||||
战斗服务于矿井探索中的风险与节奏变化,不扩展为高难度动作或装备构筑主轴。S06 负责敌人行为、攻击与伤害判定、战斗结果和战利品请求;通过 S02、S04、S07、S08 等系统完成玩家状态、位置、物品和经验更新。
|
||||
|
||||
## 玩家行动
|
||||
- 移动、观察敌人攻击范围和行为状态。
|
||||
- 普通攻击、重攻击或使用装备技能。
|
||||
- 防御、闪避、格挡或利用场景短暂规避伤害。
|
||||
- 使用食物、药剂等消耗品。
|
||||
- 拾取战利品、调查宝箱或选择继续深入。
|
||||
- 在满足条件时撤退,保留已结算的奖励。
|
||||
战斗不属于首个日常原型。后续矿井原型暂按实时操作展开,先验证移动避让、普通攻击、补给与撤退;重攻击、独立闪避或格挡技能、首领等内容暂未纳入。以下是供验证的方案,尚未形成试玩结论;生命与体力关系、具体判定参数等缺口需在战斗进入施工范围前补齐。
|
||||
|
||||
通用流程:
|
||||
`进入遭遇 → 读取敌人状态 → 玩家行动 → 敌人响应 → 结算伤害/效果 → 判断胜负或撤退`
|
||||
## 遭遇与行动
|
||||
|
||||
## 取舍表
|
||||
玩家经 S04 进入可战斗区域,S06 根据该区域的遭遇配置和已有敌人状态建立遭遇。进入区域本身不消费补给或发放奖励;消耗发生在实际行动成功时。
|
||||
|
||||
| 决策 | 立即收益 | 延迟收益 | 主要代价 |
|
||||
|---|---|---|---|
|
||||
| 继续深入还是安全撤退 | 更多资源 | 更高风险与返程压力 | 已得战利品可能损失 |
|
||||
| 消耗品现在用还是留着 | 维持当前探索 | 应对更强敌人 | 局部战况恶化 |
|
||||
| 快速击败还是稳健闪避 | 节省时间 | 降低受伤风险 | 补给与时间消耗 |
|
||||
| 高伤高耗装备还是基础攻击 | 更快击杀 | 稳定与低消耗 | 资源消耗大 |
|
||||
| 资金投武器防具还是农场设施 | 战斗能力 | 农场产能 | 另一侧进度放缓 |
|
||||
玩家观察敌人位置与攻击准备,选择接近攻击、移动避让、使用补给或沿可用出口撤退。普通攻击先检查武器、距离、方向和动作间隔,再按命中规则结算;攻击范围、伤害计算与动作间隔的具体定义尚待补齐。补给的持有和消耗由 S07 处理,恢复效果交 S02 更新,使用失败不能只扣除物品。
|
||||
|
||||
## 状态与规则
|
||||
### 玩家战斗状态
|
||||
- 当前生命、最大生命和状态效果。
|
||||
- 装备中的武器、防具、饰品和消耗品。
|
||||
- 攻击、防御、移动、闪避和技能冷却状态。
|
||||
- 当前战斗区域、遭遇编号和撤退状态。
|
||||
### 敌人状态
|
||||
敌人状态至少包括待机、警觉、攻击前摇、攻击中、受击、眩晕、死亡和撤退。
|
||||
每个敌人的实例数据(生命、位置、目标、状态效果、掉落引用)的字段定义由技术文档层承接。
|
||||
### 战斗规则
|
||||
- 只有满足攻击距离、方向、冷却和装备条件时,攻击才可结算。
|
||||
- 伤害由攻击来源属性、目标防御、技能倍率和状态效果共同决定。
|
||||
- 敌人攻击必须有可识别的前摇或预警,给予玩家反应与撤退机会。
|
||||
- 生命降至零时进入死亡或昏倒状态;具体惩罚由体力与状态系统处理。
|
||||
- 敌人死亡后只结算一次经验与战利品,并写入遭遇状态,避免重复领取。
|
||||
- 撤退后已完成的战斗奖励保留,未击败敌人按区域刷新规则处理。
|
||||
### 区域遭遇
|
||||
- 危险区域由敌人组、刷新规则、深度或阶段配置组成。
|
||||
- 进入更深区域可以提高敌人强度、资源价值和特殊遭遇概率。
|
||||
- 区域难度应通过可理解的装备、区域和任务条件表达,不依赖突然的数值墙。
|
||||
- 宝箱、精英敌人和首领可作为独立遭遇类型,但不在最小版本中同时扩张。
|
||||
击败一个敌人后可继续探索,不自动结束区域活动。沿出口离开时保留已入账的物品与经验;玩家倒下时进入失败处理,不能按安全撤退结算。日终等中断与伤害、拾取同时发生时的处理顺序,需要在矿井原型前明确。
|
||||
|
||||
## 数值与数据交接(→技术文档层)
|
||||
本系统交由技术文档层(数值策划)定义的数据类别:敌人配置、敌人行为配置、武器配置、技能配置、遭遇配置、战利品配置、状态效果配置。
|
||||
## 敌人行为与结果
|
||||
|
||||
随交接附下的设计侧定性约束:
|
||||
- 敌人数据拆分为"是什么 / 怎么行动 / 掉什么"三类,使难度与经济可独立调节。
|
||||
- 普通敌人不应稳定掉落大量高价值物品;战斗收益主要由矿物、经验和区域发现组成。
|
||||
- 稀有材料是"有明确用途的探索奖励",但必须保留任务、宝箱等补充渠道,避免战斗失败后无法推进。
|
||||
- 基础战斗允许玩家一日内完成少量遭遇并安全返程,不要求连续刷怪。
|
||||
- 失败保留已结算的普通战利品,主要损失是时间、位置或少量金钱,不清空背包。
|
||||
- 自动化收益节省日常体力,但不能让玩家跳过农场维护的全部决策。
|
||||
- 收益回流方向:区域 → 敌人 → 材料 → 加工 → 农场自动化;战斗不直接取代农场收入。
|
||||
本原型以能接近玩家并进行近身攻击的普通敌人为起点:发现玩家后接近,进入攻击距离后给出可识别的准备动作,再执行攻击并恢复。失去目标后的行为、受击是否打断、离开区域后的恢复方式还需补齐;不为所有敌人预设眩晕、撤退等完整状态集合。
|
||||
|
||||
## 反馈
|
||||
- 攻击命中、受击、闪避、格挡和暴击提供清晰的视觉与声音反馈。
|
||||
- 敌人显示生命、预警、当前状态和可攻击时机。
|
||||
- 玩家生命、补给、冷却和撤退可用性持续可见。
|
||||
- 战斗胜利显示经验、战利品和区域进度。
|
||||
- 失败说明主要原因,并明确损失、保留内容和可恢复路径。
|
||||
攻击准备应让玩家看懂危险并有机会应对,实际时长和表现通过试玩调整。伤害只在有效命中时结算,不能因动画或反馈重复播放而多次扣除。敌人被击败后停止行动,并为该次击败结算一次战利品和经验;拾取或存读档不能再次领取同一次奖励。
|
||||
|
||||
## 内部循环
|
||||
### 单次战斗循环
|
||||
`观察敌人 → 选择攻击或防御 → 处理敌人响应 → 造成或承受伤害 → 调整策略 → 击败或撤退`
|
||||
### 危险区域循环
|
||||
`准备装备与补给 → 进入区域 → 战斗与搜刮 → 判断继续深入或返程 → 带回资源 → 升级能力`
|
||||
### 长期循环
|
||||
`获得战斗经验与装备 → 提升生存能力 → 挑战更深区域 → 获得稀有资源 → 解锁新制作、任务或地图`
|
||||
继续深入可以获得更多资源和经验,也会消耗时间、生命或补给并增加倒下风险;提前撤退保留当前收获,但放弃本次继续探索的机会。矿井中倒下沿用顶层设计:损失部分金钱或物品,保留大部分长期积累,补充准备后可以再次探索。具体损失范围和幅度尚未确定,不承诺所有已入账战利品都免于失败损失。
|
||||
|
||||
## 输入、输出与依赖
|
||||
### 输入
|
||||
- 探索与地图系统提供战斗区域、位置和遭遇入口。
|
||||
- 时间系统提供当前时间、季节和日终信号。
|
||||
- 体力与状态系统提供生命、体力、状态效果和失败处理。
|
||||
- 物品系统提供武器、防具、消耗品和战利品接收入口。
|
||||
- 成长系统提供属性、技能和装备解锁。
|
||||
- 玩家通过核心玩法系统提交战斗行动。
|
||||
### 输出
|
||||
- 向物品系统提交战利品和消耗品变化。
|
||||
- 向成长系统提交战斗经验和能力进度。
|
||||
- 向地图系统提交敌人、宝箱和遭遇状态。
|
||||
- 向任务与社区系统提交击败、调查和区域进度。
|
||||
- 向 UI 输出战斗状态、反馈、胜负和撤退结果。
|
||||
战斗收益服务于本例的探索与生活成长,具体掉落和经济关系结合物品用途及收益平衡确定;本例的取向不作为其他游戏的通用战斗限制。
|
||||
|
||||
## 边界与非目标
|
||||
- 不负责通用生命与昏倒惩罚,只提交状态变化。
|
||||
- 不负责武器物品的背包、耐久和售价主数据。
|
||||
- 不负责字段定义、数值配置与表格结构——归技术文档层(数值策划)。
|
||||
- 不做高难度动作连招、复杂多人战斗或精确帧竞速。
|
||||
- 不让战斗成为获得普通农场资源的唯一方式。
|
||||
- 不在本系统中定义全部敌人、武器和首领内容。
|
||||
## 协作与数据归属
|
||||
|
||||
## 开放问题
|
||||
- 战斗采用实时操作,还是更简化的节奏/指令判定?
|
||||
- 体力是否影响攻击与闪避,还是只影响探索和农务?
|
||||
- 武器是否有耐久度,还是通过升级与装备更换形成消耗?
|
||||
- 战斗失败的主要成本采用金钱、位置、时间,还是有限组合?
|
||||
| 内容 | 负责方与协作 |
|
||||
|---|---|
|
||||
| 敌人行为、战斗判定和击败记录 | S06 维护;通过 S04 执行位置变化,使用实际位置进行判定 |
|
||||
| 玩家生命、体力、状态效果和倒下处理 | S02 接收 S06 的伤害或成本请求,协调失败后果;生命是否与体力共池尚待明确 |
|
||||
| 区域、出入口与角色位置 | S04 提供,S06 据此判断遭遇和撤退;敌人刷新条件由 S06 与区域生命周期衔接 |
|
||||
| 武器、补给、战利品身份和持有 | S07 维护;S06 引用物品标识与已确定的战斗属性,提交消耗或获得请求 |
|
||||
| 战斗经验与能力 | S08 接收击败结果并更新经验;S06 使用已生效的能力结果 |
|
||||
| 失败损失与时间 | S09 更新金钱,S07 更新物品,S04 更新位置;S01 提供时间及日终通知,各系统按明确的失败或中断结果更新 |
|
||||
| 任务进度与呈现 | S11 接收相关击败结果;UI 展示权威状态、接受操作请求,不自行判定伤害或发奖 |
|
||||
|
||||
物品入账失败时,待领取奖励如何保留、离开区域后能否再取,需在矿井原型前确定。存档保存敌人、奖励与各系统已完成的结果,恢复后不得重复发奖。具体更新顺序、持久化与恢复协议由 TDD 落实。
|
||||
|
||||
实现所需的数据包括敌人行为与属性、攻击判定、区域遭遇、物品引用、奖励和失败后果。已确定的规则与参数保留在设计中,由 TDD 收编并补齐字段、配置、计算方式和默认值,不仅交接数据类别名称。
|
||||
|
||||
## 反馈与验证
|
||||
|
||||
玩家应能识别敌人的攻击准备、命中或受伤结果、当前生存状态与补给使用结果。无法攻击、使用物品或撤退时说明当前原因;倒下后说明损失、保留内容和返回位置。
|
||||
|
||||
| 场景 | 判断依据 |
|
||||
|---|---|
|
||||
| 遭遇普通敌人并攻击或避让 | 玩家能理解攻击准备,伤害与实际命中一致;结合试玩反馈判断操作压力是否符合轻度战斗定位 |
|
||||
| 击败、拾取并存读档 | 物品与经验正确入账,同一次击败不会重复结算;入账受阻时按补齐后的奖励保留规则处理 |
|
||||
| 安全撤退与矿井倒下 | 撤退保留已入账成果;倒下执行明确的部分损失,提示与各系统实际结果一致 |
|
||||
| 使用补给或遭遇日终中断 | 物品与恢复结果一致,中断按补齐后的顺序结束处理,不留下部分扣除或重复收益 |
|
||||
|
||||
以上为待执行的验证场景。战斗进入实现范围前,还需明确生命与体力关系、敌人行为与刷新、伤害和动作参数、奖励入账受阻处理、失败损失及日终中断顺序,并同步 S02、S04、S07、S08、S09 和相应 TDD。
|
||||
|
||||
+47
-65
@@ -1,82 +1,64 @@
|
||||
# 美术圣经:《星露谷物语》(TDD 金样 · 美术圣经)
|
||||
# 美术圣经:《星露谷物语》首个日常原型(TDD 示例)
|
||||
|
||||
> 状态:reviewed | 定调锚:概念层@v1 第 2 节(定调记录:牧场物语系参照、压力低/节奏慢/治愈) | style_id:`stardew_warm_rural_pixel`
|
||||
> 实证规格来源:星露谷 1.6.15 解包知识库 v3(资产计数时点 2026-09-11,快照 stardew-1.6.15-7f1e5b8e)。写新项目时按本项目定调重译,数字仅作规模参照。
|
||||
## 范围与设计依据
|
||||
|
||||
## 视觉风格总览
|
||||
本例只覆盖首个日常原型:农场、小镇、基础采集区域;耕地、播种、浇水、跨日生长、收获、买种、出售,以及时间、天气、体力、背包、金钱和日终反馈。它承接同目录 `stardew-concept.md` 的“身份、基调与世界观”“边界与约束”,以及 `stardew-architecture.md` 的“首个原型范围”“实现范围与验证”。矿井、战斗、NPC 日程、关系、钓鱼、畜牧、节日、多人和大批换装属于后续范围,本例不为它们预配首期图集。
|
||||
|
||||
从定调记录翻译的视觉气质:**"被四季照亮的温暖小农场"**——手绘感像素、俯视 45° 视角,春夏绿意、秋日暖橙、冬季留白,颜色随季节整体切换而不是换贴图;物件轮廓圆润、无锐利科技感。玩家一看画面就该感到:这里节奏很慢,干活是安心的。参考图位 4 张(量产流程第 3 步产出锚点图)。
|
||||
视觉依据是温暖乡村、复古像素、轻度压力和可反复游玩的日常节奏,来源版本见总册。**目前没有已选定的参考图、画风卡或可验收的源素材**;以下色值和尺寸是示例策划假设,供后续视觉确认。目标运行时是新建 2D Web 原型(npm + Vite + Phaser 4.2.1)。美术源文件和导出资源尚未产出;预定资源入口为项目内 `assets/art/source/`(可编辑源文件)、`game/public/assets/art/`(PNG 和帧表 JSON)与 `game/public/assets/audio/`(音频)。运行素材由 Vite 复制到 `game/dist/assets/`,程序按构建内相对路径加载;这些是预定交付位置,不表示文件已存在。
|
||||
|
||||
## 视觉锚
|
||||
## 视觉规则
|
||||
|
||||
- 关键词:温暖、手绘像素、田园、四季分明、生活感。
|
||||
- 禁用关键词:阴暗压抑、血腥恐怖、高饱和霓虹、写实渲染、锐利科技风(承概念层 T3"战斗轻度"、T7"日常叙事")。
|
||||
- 色板:主色 暖土绿系(草地/耕地基底)60% / 辅色 暖木棕+瓦顶红 30% / 点缀 季节信号色(春樱粉/夏浓绿/秋橙/冬蓝白)10%。昼夜·天气·季节表现:季节=色调与植被整体切换;天气=雨天全屏冷色叠加(原作 OrangeRed×0.45 实证);昼夜=时刻线性插值环境光。
|
||||
- 形状语言:圆润矩形轮廓,物件以 16px 网格对齐;无 1px 高光乱线。
|
||||
- 比例与轮廓:物件 16px 一档;NPC 16×32(渲染放大 4 倍);玩家可完全自定义外观。
|
||||
- 光照与材质:不做真实光照——低分辨率 lightmap 乘法混合;优先级链=矿井 tint>室内 ambient>室外 outdoor(时刻插值);十种光源贴图常量够用。
|
||||
- 渲染口径:纯像素、无抗锯齿、整数倍缩放(程序侧能力边界同源)。
|
||||
- 关键词:温暖、朴素、清爽、生活感。避免血腥、霓虹、高反光写实材质、尖锐科技造型和繁密的随机像素噪点。
|
||||
- 色板示例假设:草地基色 `#78A85A`、泥土 `#9D7048`、木材 `#AA724C`、纸面 `#F2E1B9`、操作提示 `#E8BE62`、不可用 `#738291`。这些是设计锚点,实际调色板及色弱可辨性须在首批视觉样张中确认;状态不能只靠颜色区分。
|
||||
- 画面采用近正上方的斜俯视像素场景,地面按 16×16 世界像素网格排布。人物脚点落在格中心下沿;可遮挡的建筑/树冠在人物之上,地面和作物基座在人物之下。建筑、箱子和植物用圆润轮廓,亮面集中在左上,阴影不使用柔边渐变。
|
||||
- 游戏世界用最近邻采样、整数倍缩放和像素对齐;示例假设移动端世界像素放大 3 倍、桌面端 4 倍,布局可裁剪视野但不拉伸像素。HUD 使用清晰文字与图形,不把 16px 图标放大后作为 44 CSS px 的触控热区;热区由 UI 布局提供。
|
||||
- 当前范围只需晴、雨和日间至傍晚的可辨反馈。雨天在地面与图标之外加低强度冷色层和雨线;傍晚用统一环境色层。天气图示另带“晴/雨”文字,避免仅靠色调识别。季节全套换景不是首期要求。
|
||||
|
||||
## 角色模板
|
||||
## 类别规格
|
||||
|
||||
- 基础规则:玩家=换装组合而非整图——19 个独立层(基础体/裤/衣/发型/饰件/配件…),每层独立图集,调色板像素 256-277 区域换色实现同图集多变色;层深=基准+层序×1e-6 保证叠加次序稳定。NPC=16×32 四方向小人+64×64 立绘(对话用)。
|
||||
- 方向数:4 方向(左=右镜像:是——行走图按方向分行布局,如 64×448=4 列×14 行)。
|
||||
- 动画状态:待机/走 walk=4 帧循环、每帧 200ms(帧表含毫秒级帧时长);受击/使用工具按动作逐条登记帧表。玩家帧表量大(原作 500+ 动画参数为 switch 硬编码),本项目帧表走数据表不走硬编码。
|
||||
- 立绘表情:六表情索引 0-5($neutral/$happy/$sad/$unique/$love/$angry),对话文本中 `$表情` 标记驱动切换。
|
||||
下表参数均为示例假设;后续确定时应在本分册更新,不能把本段视为已经产出的资产事实。帧键按对象标识及适用的状态、方向、帧序组成;同类共性只定义一次。
|
||||
|
||||
## 场景模板
|
||||
| 类别 | 共性规格 | 命名与交付格式 | 运行时消费 | 后续验收判据 |
|
||||
|---|---|---|---|---|
|
||||
| 地形图块 | 每格 16×16;地面可无透明,边缘变体不留缝;绘制时留 1px 图集挤出边防采样渗色 | `tile_{terrain}_{variant}`,PNG 图集+JSON 帧表 | 地图按 `region_id` 的格子与图层取帧;湿地块读取地块状态,不复制一张整农场图 | 每帧 16×16、图集无渗色;干湿耕地与普通土路在目标缩放下能区分 |
|
||||
| 场景物 | 按占格记录脚点和遮挡高度;静态物 1 帧,交互状态单列 | `prop_{object}_{state}`,PNG 图集+JSON 帧表 | 地图对象标识决定帧;遮挡层按脚点排序,交互热点由地图数据给出 | 图像、脚点、碰撞/热点对齐;可交互物与背景有轮廓差异 |
|
||||
| 玩家 | 单个 16×32 角色,不做换装层;待机每方向 1 帧、走路每方向 4 帧,工具动作是否专帧待确定 | `player_{state}_{dir}_{frame}`,PNG 图集+JSON 帧表;方向 `down/up/left/right`,右可镜像左 | 移动状态驱动待机/行走,帧表注明顺序和时长;脚点固定在帧底中心 | 四方向基准点一致,镜像后工具手势不误导;行走不跳格或抖动 |
|
||||
| 作物与采集点 | 作物占 1 格,按数据侧生长状态取 16×32 透明帧;采集点包含可采和已采状态 | `{crop_id}_{stage}` / `{forage_node_id}_{state}`,PNG 图集+JSON 帧表 | 数据状态映射到帧键,成熟和可采必须有独立轮廓;状态数以数据分册最终定义为准 | 各状态有唯一帧键,无缺帧;未熟/成熟、可采/已采在移动视口能辨认 |
|
||||
| 物品图标 | 16×16,透明背景,单帧;工具和产物同一盒内留 1px 内边距 | `icon_{item_id}`,PNG 图集+JSON 帧表 | 背包、商店、出售清单按 `item_id` 查图标;金额、数量由 UI 文字绘制 | 图标键与物品表逐项匹配,无空白或越界;种子、产物、工具形状可区分 |
|
||||
| UI | 面板、槽位、进度条用 CSS 或九宫格按界面实现,文字走字体系统;天气图示与操作提示可为 16×16 图标 | 需要图片时用 `ui_{element}_{state}`;UI 图 PNG,文字不烘入纹理 | HUD、背包、商店、出售和日终视图读取权威状态;禁用态以图形与文字共同提示 | 数字与图标不重叠;移动端本例暂定触控热区至少 44 CSS px,桌面/移动两视口可读 |
|
||||
| 音频 | BGM 暂定 OGG 循环、目标 -18 LUFS;SFX 暂定 WAV 单发,均为本例假设;时长、采样与混音参数待补齐 | `bgm_{usage}` / `sfx_{action}`,独立文件;循环点随资源给出 | 首次用户操作后启用音频;按游戏事件触发,具体绑定由技术分册补齐 | 在目标浏览器可解码,循环无接缝,事件不重复触发,反馈清楚且不盖过其他必要提示 |
|
||||
|
||||
- tileset 规格:16px tile;TileSheets 级图集约 41 张+地形特征图集 38 张(作物/树);padding 1px 防渗色。
|
||||
- 图层拆分:四层 Back/Buildings/Front/AlwaysFront(深度 -1/0.1/64+/-1)——地面/建筑/前景遮挡/最前;碰撞由 Buildings 层属性驱动;矿井布局池按模板拼装(原作 61 张模板,本项目首期 8~12 张)。
|
||||
- 场景对象规则:多帧素材禁当静态贴图(作物生长/角色必须走帧表);单元素禁整图(区域由 tile 拼装,禁为每区域画整张立绘);地图数参照:原作 259 tmx+304 预览,本项目首期 3 区域(农场/小镇/矿井)。
|
||||
## 当前范围对象清单与例外
|
||||
|
||||
## UI 视觉
|
||||
清单用对象组列出有限变体,适用上面的类别规格。物品和作物标识以数据分册为准;若数据分册尚未给完整首期清单,下列命名只作示例,**不能据此宣称原型素材范围已全量对账**。
|
||||
|
||||
承 UI 系统文档界面清单(HUD/背包/商店/对话/日终结算五界面)。视觉语言:木质面板底+纸张质感对话框;信息分层——价格信息永远暖金、锁定/禁用永远灰蓝、日终收入单列。字体用位图字体(原作 5 fnt 位图字体实证)。触控版式热区 ≥44px 与程序侧输入表同源。特效走程序动画(按帧表播图集区域)+粒子,不逐特效画整图(原作 LooseSprites 156 张 UI/杂项图集规模参照;最大图集实测 1920×1376)。
|
||||
|
||||
## 素材规格契约
|
||||
|
||||
| 素材 | 规格(尺寸/帧数/方向数) | 命名规则 | atlas 格式 | 验收 | 绑定 |
|
||||
| 对象或明确对象组 | 类别 | 标识/数据绑定 | 状态与变体 | 规格例外或无图像资产的处理 | 消费位置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 物品图标 | 16×16,1 帧 | `icon_{item_id}` | JSON atlas | 技术+视觉 | `item_*` 全量(参照原作 807;本项目首期 ~120 行) |
|
||||
| 作物 | 16×32×相位帧(4~5 相位,每相位 1~2 帧) | `crop_{crop_id}` | JSON atlas | 技术+视觉 | `crop_*`(参照原作 50) |
|
||||
| 玩家换装层 | 每层独立图集,walk 4 帧×4 方向 | `farmer_{layer}_{state}_{dir}` | JSON atlas | 技术+视觉 | 豁免(角色非物品,登记于本表) |
|
||||
| NPC 行走+立绘 | 行走 16×32 四方向行布局;立绘 64×64×6 表情 | `npc_{id}_walk` / `npc_{id}_portrait_{expr}` | JSON atlas | 技术+视觉 | `npc_*`(参照原作 34 社交 NPC/101 立绘;本项目首期 8 位) |
|
||||
| tileset | 16px,边缘连接变体 | `tile_{theme}_{variant}` | JSON atlas | 技术+视觉 | 豁免(场景组件) |
|
||||
| UI 面板 | 9 宫格切片,3 态 | `ui_{element}_{state}` | JSON atlas | 技术+视觉 | 豁免(UI) |
|
||||
| BGM | ogg,-18LUFS,循环点标记 | `bgm_{season}` 4 首+矿井 1 首 | — | 响度+循环 | 豁免(音频契约) |
|
||||
| SFX | wav 单发 | `sfx_{event}`(事件↔音效映射表登记) | — | 同帧触发 | 豁免(音频契约) |
|
||||
| 农场、小镇、基础采集区域 | 地形图块/地图 | `region_id`:`farm`、`town`、`forage`(示例假设) | 草地、土路、干耕地、湿耕地、边界与出入口;必要的相邻边缘变体 | 每区由 tile 与对象图层拼装;地图格、出口和碰撞数据需随地图一同交付,不能以整图代替 | S04 场景加载、S03 地块展示 |
|
||||
| 农舍外观、出货箱、小镇种子商店门牌 | 场景物 | 地图对象 `farmhouse`、`shipping_bin`、`seed_shop` | 默认;出货箱可交互、商店开/关由 UI 文本或标记提示 | 农舍可跨多格,需给实际占格与脚点;不为商店首期制作有日程的店员 | S04 地图与 S09 商店/出售入口 |
|
||||
| 野外采集点 | 作物与采集点 | `forage_node_id` 对应地图资源点 | 可采、已采;刷新后回可采,触发时机待 S04/S05 定义 | 采集物外观与其背包图标可以不同 | S04 资源点、S05 采集反馈 |
|
||||
| 防风草作物 | 作物与采集点 | `crop_id=crop_parsnip`(数据分册局部示例配置) | 播种后各生长阶段、成熟;浇水通过地块湿态呈现 | 数据样例仅给 `growth_days=4`,四次满足条件的跨日不等于四个视觉阶段;阶段数和映射待定 | S03 地块投影 |
|
||||
| 玩家 | 玩家 | `player` | 四方向待机、行走;工具动作待确定 | 不做 19 层换装、立绘或 NPC 表情 | S04 移动、S03 农务、S05 采集 |
|
||||
| 防风草种子、防风草 | 物品图标 | `item_id=parsnip_seed`、`parsnip`(数据分册局部示例配置) | 每物品 1 图;数量与价格均由文字显示 | 只覆盖局部示例配置,不代表首期全量物品 | 背包、商店、出售清单 |
|
||||
| 采集物、锄头、水壶 | 物品图标 | `wild_berry`、`hoe`、`watering_can` 是待数据确认的示意标识 | 每物品 1 图;数量与价格均由文字显示 | 数据规则与数值未定,不能据此进入全量生产;金币无需物品图标 | 背包、商店、出售清单 |
|
||||
| 时间/天气/体力/金钱、背包、商店、出售、日终与存读档 | UI | S01/S02/S07/S09 状态与界面标识 | 晴/雨图示,体力进度与低体力提示,可买/不可买、可卖/不可卖、结算前后 | 数值、名称、日期、价格和说明由 UI 文本绘制;进度条和槽位可程序绘制,按界面无需逐状态出图 | HUD 与各界面 |
|
||||
| 日常环境音乐与行动反馈 | 音频 | 暂定 `bgm_daily`,以及耕地、播种、浇水、收获、采集、购买、出售、日终的 `sfx_{action}` | 单一日常循环与各成功事件单发;失败提示是否需要独立音效待定 | 不预填四季或矿井音乐;精确事件、文件名与音量映射待补齐 | 场景音频、S03/S05/S09 与日终反馈 |
|
||||
|
||||
- 绘制工艺:按项目实际制作路径逐类记录参数与封装流程;施工环境无产出通道时规格先行锁定、状态如实登记"缺失"。
|
||||
- 音频契约说明:原作 XACT cue 名 435 候选/代码引用 230 个——本项目首期 SFX 事件 20 只起步,按事件总线 `sfx_event` 映射表登记,不逐 cue 复刻。
|
||||
- 豁免类型仅限:程序化生成(矿井布局由表驱动拼装)/ UI 文本 / 本期不需要——每项豁免在契约行写明。
|
||||
## 后续生产与接入验收
|
||||
|
||||
## 资产状态表(asset manifest)
|
||||
- 交付时保留可编辑源文件,并按类别导出 PNG 与帧表 JSON。帧表至少给帧键、矩形、脚点、状态、方向、帧时长;地图另给格子、图层、对象脚点、出口和碰撞数据。生产前先用少量地形、角色、作物、图标和 UI 样张核对色板与比例,再按已定规格扩充;样张数量由实际疑点决定。
|
||||
- 技术验收逐项对照**最终数据清单**核对对象键:图集和帧表能解析;帧矩形不越界;16×16 图标/地形与 16×32 玩家符合约定;透明通道、边缘和脚点正确;作物与采集状态都有帧。接入场景后移动、播种、浇水、跨日成长、采集、买卖和日终均能取到正确帧或程序绘制状态,缺帧或错误状态即不通过。
|
||||
- 视觉验收在桌面与移动视口截取农场、小镇、采集、背包、商店、出售和跨日后的画面,对照本分册检查乡村像素风、角色脚点、干湿地块、作物未熟/成熟、可采/已采、天气与禁用态。请观察者不看说明指出可操作对象与关键状态;若只能靠颜色或反复试错识别,就需调整形状/文字提示后复验。
|
||||
- 这套检查是**后续生产和接入的判据**,不表示已有素材通过。新增物品、区域或状态时,先更新数据/系统清单,再更新本页对象、帧键和消费映射。
|
||||
|
||||
| asset_id | 规格 | 绑定 | 状态 | 验收记录 | contract_version |
|
||||
|---|---|---|---|---|---|
|
||||
| icon_item_*(首期 ~120 行,逐 item_id 一行) | 16×16 | `item_*` | 缺失 | — | 1 |
|
||||
| crop_*(50 行) | 16×32 相位帧 | `crop_*` | 缺失 | — | 1 |
|
||||
| farmer_*(19 层图集) | 4 帧×4 方向 | 豁免 | 缺失 | — | 1 |
|
||||
| npc_*_walk / _portrait(8 位×7 件) | 16×32 / 64×64×6 | `npc_*` | 缺失 | — | 1 |
|
||||
| tile_*(3 主题变体组) | 16px 变体 | 豁免 | 缺失 | — | 1 |
|
||||
| ui_*(5 界面套件) | 9 宫格 3 态 | 豁免 | 缺失 | — | 1 |
|
||||
| bgm_*(5 首) | ogg -18LUFS | 豁免 | 缺失 | — | 1 |
|
||||
| sfx_*(20 只) | wav | 豁免 | 缺失 | — | 1 |
|
||||
## 当前未决问题
|
||||
|
||||
- 状态单向流转:缺失 → 草稿 → 已交付 → 已验收 → 已接入;驳回退回草稿并记原因。
|
||||
- 验收两维:技术(尺寸/透明/帧数/命名)+ 视觉(对照视觉锚);两维都过才进"已验收"。
|
||||
- **每个 gameplay 可见对象必有一行或显式豁免——没有第三种状态**:物品图标逐 item_id 与数据侧物品表逐行对账(参照规模:原作 Characters 215 png/Portraits 101/TileSheets 41/TerrainFeatures 38/LooseSprites 156)。
|
||||
- 程序接入后填消费点(哪个模块加载、事件映射),`contract_version` 变更须重验收。
|
||||
|
||||
## 量产流程与验证
|
||||
|
||||
1. 概念候选 4 张(农场一角/角色/物品图标/UI 面板各 1 方向稿)→ 2. 人选方向(用户确认)→ 3. 锚点图 4 张 → 4. 锁圣经 → 5. 写契约(本文件已锁)→ 6. 小批 8 张(icon_item 子集:防风草种子/防风草/木材/石头/铜矿/锄头/水壶/出货箱)→ 7. 技术检查(尺寸/透明/命名/atlas 解析)→ 8. 接入程序(v0.1 里程碑)→ 9. 运行时截图验收(桌面/移动双视口下 16×16 图标与作物相位可辨、四季色调正确)→ 10. 扩产(8→120→全量)。
|
||||
|
||||
## 开放问题回执
|
||||
|
||||
| # | 问题 | 去向 |
|
||||
| 问题 | 对当前施工的影响 | 下一步与需更新的位置 |
|
||||
|---|---|---|
|
||||
| 1 | 换装系统首期是否做全 19 层(或缩到 5 层) | → 台账代决(建议首期 5 层:基础体/裤/衣/发型/饰件;台账 D-17) |
|
||||
| 2 | 锚点图方向需用户确认 | → 施工期提案卡 |
|
||||
| 3 | 位图字体 vs 矢量像素风字体 | → 小批阶段随 UI 套件定 |
|
||||
| 数据分册目前只有 `parsnip_seed`、`parsnip` 与 `crop_parsnip` 的局部示例配置;完整物品/采集点 ID 与作物视觉阶段未定 | 无法最终对账图标和作物帧键,也不能给出全量资产数;`growth_days=4` 不能代替阶段映射 | 数据分册定稿后填入对象清单及物品、作物、采集点帧键映射 |
|
||||
| 技术分册暂定农场 80×65 格、小镇 50×40 格;采集区域尺寸、各区域出口、遮挡物占格与碰撞仍不完整 | 地形和场景物的实例数量、脚点与地图数据无法施工 | 地图规格确定后补地图对象表和场景物例外,并同步 S04 |
|
||||
| 视觉锚图、实际调色板与字体尚未选定 | 可按文字规则做方向稿,但最终视觉一致性与 UI 字符可读性仍需评审 | 选择可访问的锚图与字体资源后更新“范围与设计依据”“视觉规则”“类别规格” |
|
||||
| 工具动作帧和具体 UI 布局尚未确定 | 农务动作与移动端操作反馈无法完成逐状态切图 | 交互规格确定后补玩家动作帧和 UI 状态映射 |
|
||||
| 图集 JSON 格式、帧时长与音频参数/事件映射未全定 | 程序无法直接加载所有动画或绑定声音 | 明确与 Phaser 加载方式一致的格式和映射,同步技术分册 |
|
||||
|
||||
本例展示如何写出当前范围的规格与后续验收方法;以上缺口仍影响实际施工,因此不宣称首个原型的美术策划案已经完备。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user