feat(game): 添加游戏内容配置系统和VS Code插件支持

- 实现YAML配置文件管理和JSON Schema验证功能
- 提供运行时只读查询和Source Generator代码生成功能
- 开发VS Code插件实现配置浏览、校验和轻量表单编辑
- 支持开发期热重载和配置变更自动刷新机制
- 集成诊断功能提供配置文件错误提示和修复建议
This commit is contained in:
GeWuYou 2026-03-31 22:49:10 +08:00
parent e8d0ea2daf
commit 0c662ced2a
5 changed files with 288 additions and 50 deletions

View File

@ -144,7 +144,7 @@ var hotReload = loader.EnableHotReload(
- 打开 raw YAML 文件 - 打开 raw YAML 文件
- 打开匹配的 schema 文件 - 打开匹配的 schema 文件
- 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验 - 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验
- 对顶层标量字段提供轻量表单入口 - 对顶层标量字段和顶层标量数组提供轻量表单入口
当前仍建议把复杂数组、嵌套对象和批量修改放在 raw YAML 中完成。 当前仍建议把复杂数组、嵌套对象和批量修改放在 raw YAML 中完成。
@ -154,6 +154,6 @@ var hotReload = loader.EnableHotReload(
- 跨表引用校验 - 跨表引用校验
- 更完整的 JSON Schema 支持 - 更完整的 JSON Schema 支持
- 更强的 VS Code 表单编辑器 - 更强的 VS Code 嵌套对象与复杂数组编辑器
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。 因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。

View File

@ -8,7 +8,7 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
- Open raw YAML files - Open raw YAML files
- Open matching schema files from `schemas/` - Open matching schema files from `schemas/`
- Run lightweight schema validation for required fields, unknown top-level fields, scalar types, and scalar array items - Run lightweight schema validation for required fields, unknown top-level fields, scalar types, and scalar array items
- Open a lightweight form preview for top-level scalar fields - Open a lightweight form preview for top-level scalar fields and top-level scalar arrays
## Validation Coverage ## Validation Coverage
@ -32,8 +32,8 @@ node --test ./test/*.test.js
- Multi-root workspaces use the first workspace folder - Multi-root workspaces use the first workspace folder
- Validation only covers a minimal subset of JSON Schema - Validation only covers a minimal subset of JSON Schema
- Form editing currently supports top-level scalar fields only - Form editing currently supports top-level scalar fields and top-level scalar arrays
- Arrays and nested objects should still be edited in raw YAML - Nested objects and complex arrays should still be edited in raw YAML
## Workspace Settings ## Workspace Settings

View File

@ -227,44 +227,85 @@ function isScalarCompatible(expectedType, scalarValue) {
} }
/** /**
* Apply scalar field updates back into the original YAML text. * Apply form field updates back into the original YAML text.
* The current form editor supports top-level scalar fields and top-level scalar
* arrays, while nested objects and complex arrays remain raw-YAML-only.
*
* @param {string} originalYaml Original YAML content.
* @param {{scalars?: Record<string, string>, arrays?: Record<string, string[]>}} updates Updated form values.
* @returns {string} Updated YAML content.
*/
function applyFormUpdates(originalYaml, updates) {
const lines = originalYaml.split(/\r?\n/u);
const scalarUpdates = updates.scalars || {};
const arrayUpdates = updates.arrays || {};
const touchedScalarKeys = new Set();
const touchedArrayKeys = new Set();
const blocks = findTopLevelBlocks(lines);
const updatedLines = [];
let cursor = 0;
for (const block of blocks) {
while (cursor < block.start) {
updatedLines.push(lines[cursor]);
cursor += 1;
}
if (Object.prototype.hasOwnProperty.call(scalarUpdates, block.key)) {
touchedScalarKeys.add(block.key);
updatedLines.push(renderScalarLine(block.key, scalarUpdates[block.key]));
cursor = block.end + 1;
continue;
}
if (Object.prototype.hasOwnProperty.call(arrayUpdates, block.key)) {
touchedArrayKeys.add(block.key);
updatedLines.push(...renderArrayBlock(block.key, arrayUpdates[block.key]));
cursor = block.end + 1;
continue;
}
while (cursor <= block.end) {
updatedLines.push(lines[cursor]);
cursor += 1;
}
}
while (cursor < lines.length) {
updatedLines.push(lines[cursor]);
cursor += 1;
}
for (const [key, value] of Object.entries(scalarUpdates)) {
if (touchedScalarKeys.has(key)) {
continue;
}
updatedLines.push(renderScalarLine(key, value));
}
for (const [key, value] of Object.entries(arrayUpdates)) {
if (touchedArrayKeys.has(key)) {
continue;
}
updatedLines.push(...renderArrayBlock(key, value));
}
return updatedLines.join("\n");
}
/**
* Apply only scalar updates back into the original YAML text.
* This helper is preserved for compatibility with existing tests and callers
* that only edit top-level scalar fields.
* *
* @param {string} originalYaml Original YAML content. * @param {string} originalYaml Original YAML content.
* @param {Record<string, string>} updates Updated scalar values. * @param {Record<string, string>} updates Updated scalar values.
* @returns {string} Updated YAML content. * @returns {string} Updated YAML content.
*/ */
function applyScalarUpdates(originalYaml, updates) { function applyScalarUpdates(originalYaml, updates) {
const lines = originalYaml.split(/\r?\n/u); return applyFormUpdates(originalYaml, {scalars: updates});
const touched = new Set();
const updatedLines = lines.map((line) => {
if (/^\s/u.test(line)) {
return line;
}
const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line);
if (!match) {
return line;
}
const key = match[1];
if (!Object.prototype.hasOwnProperty.call(updates, key)) {
return line;
}
touched.add(key);
return `${key}: ${formatYamlScalar(updates[key])}`;
});
for (const [key, value] of Object.entries(updates)) {
if (touched.has(key)) {
continue;
}
updatedLines.push(`${key}: ${formatYamlScalar(value)}`);
}
return updatedLines.join("\n");
} }
/** /**
@ -329,8 +370,90 @@ function parseTopLevelArray(childLines) {
return items; return items;
} }
/**
* Find top-level YAML blocks so form updates can replace whole entries without
* touching unrelated domains in the file.
*
* @param {string[]} lines YAML lines.
* @returns {Array<{key: string, start: number, end: number}>} Top-level blocks.
*/
function findTopLevelBlocks(lines) {
const blocks = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!line || line.trim().length === 0 || line.trim().startsWith("#") || /^\s/u.test(line)) {
continue;
}
const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line);
if (!match) {
continue;
}
let cursor = index + 1;
while (cursor < lines.length) {
const nextLine = lines[cursor];
if (nextLine.trim().length === 0 || nextLine.trim().startsWith("#")) {
cursor += 1;
continue;
}
if (!/^\s/u.test(nextLine)) {
break;
}
cursor += 1;
}
blocks.push({
key: match[1],
start: index,
end: cursor - 1
});
index = cursor - 1;
}
return blocks;
}
/**
* Render a top-level scalar line.
*
* @param {string} key Property name.
* @param {string} value Scalar value.
* @returns {string} Rendered YAML line.
*/
function renderScalarLine(key, value) {
return `${key}: ${formatYamlScalar(value)}`;
}
/**
* Render a top-level scalar array block.
*
* @param {string} key Property name.
* @param {string[]} items Array items.
* @returns {string[]} Rendered YAML lines.
*/
function renderArrayBlock(key, items) {
const normalizedItems = Array.isArray(items)
? items
.map((item) => String(item).trim())
.filter((item) => item.length > 0)
: [];
const lines = [`${key}:`];
for (const item of normalizedItems) {
lines.push(` - ${formatYamlScalar(item)}`);
}
return lines;
}
module.exports = { module.exports = {
applyFormUpdates,
applyScalarUpdates, applyScalarUpdates,
findTopLevelBlocks,
formatYamlScalar, formatYamlScalar,
isScalarCompatible, isScalarCompatible,
parseSchemaContent, parseSchemaContent,

View File

@ -2,7 +2,7 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const vscode = require("vscode"); const vscode = require("vscode");
const { const {
applyScalarUpdates, applyFormUpdates,
parseSchemaContent, parseSchemaContent,
parseTopLevelYaml, parseTopLevelYaml,
unquoteScalar, unquoteScalar,
@ -247,9 +247,9 @@ async function openSchemaFile(item) {
} }
/** /**
* Open a lightweight form preview for top-level scalar fields. * Open a lightweight form preview for top-level scalar fields and scalar
* The editor intentionally edits only simple scalar keys and keeps raw YAML as * arrays. Nested objects and more complex array shapes still use raw YAML as
* the escape hatch for arrays, nested objects, and advanced changes. * the escape hatch.
* *
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
@ -279,7 +279,10 @@ async function openFormPreview(item, diagnostics) {
panel.webview.onDidReceiveMessage(async (message) => { panel.webview.onDidReceiveMessage(async (message) => {
if (message.type === "save") { if (message.type === "save") {
const updatedYaml = applyScalarUpdates(yamlText, message.values || {}); const updatedYaml = applyFormUpdates(yamlText, {
scalars: message.scalars || {},
arrays: parseArrayFieldPayload(message.arrays || {})
});
await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8"); await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8");
const document = await vscode.workspace.openTextDocument(configUri); const document = await vscode.workspace.openTextDocument(configUri);
await document.save(); await document.save();
@ -409,7 +412,7 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
* @returns {string} HTML string. * @returns {string} HTML string.
*/ */
function renderFormHtml(fileName, schemaInfo, parsedYaml) { function renderFormHtml(fileName, schemaInfo, parsedYaml) {
const fields = Array.from(parsedYaml.entries.entries()) const scalarFields = Array.from(parsedYaml.entries.entries())
.filter(([, entry]) => entry.kind === "scalar") .filter(([, entry]) => entry.kind === "scalar")
.map(([key, entry]) => { .map(([key, entry]) => {
const escapedKey = escapeHtml(key); const escapedKey = escapeHtml(key);
@ -424,13 +427,48 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
}) })
.join("\n"); .join("\n");
const arrayFields = Array.from(parsedYaml.entries.entries())
.filter(([, entry]) => entry.kind === "array")
.map(([key, entry]) => {
const escapedKey = escapeHtml(key);
const escapedValue = escapeHtml((entry.items || [])
.map((item) => unquoteScalar(item.raw))
.join("\n"));
const required = schemaInfo.required.includes(key) ? "<span class=\"badge\">required</span>" : "";
const itemType = schemaInfo.properties[key] && schemaInfo.properties[key].itemType
? `array<${escapeHtml(schemaInfo.properties[key].itemType)}>`
: "array";
return `
<label class="field">
<span class="label">${escapedKey} ${required}</span>
<span class="hint">One item per line. Expected type: ${itemType}</span>
<textarea data-array-key="${escapedKey}" rows="5">${escapedValue}</textarea>
</label>
`;
})
.join("\n");
const unsupportedFields = Array.from(parsedYaml.entries.entries())
.filter(([, entry]) => entry.kind !== "scalar" && entry.kind !== "array")
.map(([key, entry]) => `
<div class="unsupported">
<strong>${escapeHtml(key)}</strong>: ${escapeHtml(entry.kind)} fields are currently raw-YAML-only.
</div>
`)
.join("\n");
const schemaStatus = schemaInfo.exists const schemaStatus = schemaInfo.exists
? `Schema: ${escapeHtml(schemaInfo.schemaPath)}` ? `Schema: ${escapeHtml(schemaInfo.schemaPath)}`
: `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`; : `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`;
const emptyState = fields.length > 0 const editableContent = [scalarFields, arrayFields].filter((content) => content.length > 0).join("\n");
? fields const unsupportedSection = unsupportedFields.length > 0
: "<p>No editable top-level scalar fields were detected. Use raw YAML for nested objects or arrays.</p>"; ? `<div class="unsupported-list">${unsupportedFields}</div>`
: "";
const emptyState = editableContent.length > 0
? `${editableContent}${unsupportedSection}`
: "<p>No editable top-level scalar or scalar-array fields were detected. Use raw YAML for nested objects or complex arrays.</p>";
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -477,6 +515,22 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
background: var(--vscode-input-background); background: var(--vscode-input-background);
color: var(--vscode-input-foreground); color: var(--vscode-input-foreground);
} }
textarea {
width: 100%;
padding: 8px;
box-sizing: border-box;
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-family: var(--vscode-editor-font-family, var(--vscode-font-family));
resize: vertical;
}
.hint {
display: block;
margin-bottom: 6px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.badge { .badge {
display: inline-block; display: inline-block;
margin-left: 6px; margin-left: 6px;
@ -486,11 +540,20 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
color: var(--vscode-badge-foreground); color: var(--vscode-badge-foreground);
font-size: 11px; font-size: 11px;
} }
.unsupported-list {
margin-top: 20px;
border-top: 1px solid var(--vscode-panel-border, transparent);
padding-top: 16px;
}
.unsupported {
margin-bottom: 10px;
color: var(--vscode-descriptionForeground);
}
</style> </style>
</head> </head>
<body> <body>
<div class="toolbar"> <div class="toolbar">
<button id="save">Save Scalars</button> <button id="save">Save Form</button>
<button id="openRaw">Open Raw YAML</button> <button id="openRaw">Open Raw YAML</button>
</div> </div>
<div class="meta"> <div class="meta">
@ -501,11 +564,15 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
document.getElementById("save").addEventListener("click", () => { document.getElementById("save").addEventListener("click", () => {
const values = {}; const scalars = {};
const arrays = {};
for (const input of document.querySelectorAll("input[data-key]")) { for (const input of document.querySelectorAll("input[data-key]")) {
values[input.dataset.key] = input.value; scalars[input.dataset.key] = input.value;
} }
vscode.postMessage({ type: "save", values }); for (const textarea of document.querySelectorAll("textarea[data-array-key]")) {
arrays[textarea.dataset.arrayKey] = textarea.value;
}
vscode.postMessage({ type: "save", scalars, arrays });
}); });
document.getElementById("openRaw").addEventListener("click", () => { document.getElementById("openRaw").addEventListener("click", () => {
vscode.postMessage({ type: "openRaw" }); vscode.postMessage({ type: "openRaw" });
@ -638,6 +705,25 @@ function escapeHtml(value) {
.replace(/'/gu, "&#39;"); .replace(/'/gu, "&#39;");
} }
/**
* Convert raw textarea payloads into scalar-array items.
*
* @param {Record<string, string>} arrays Raw array editor payload.
* @returns {Record<string, string[]>} Parsed array updates.
*/
function parseArrayFieldPayload(arrays) {
const parsed = {};
for (const [key, value] of Object.entries(arrays)) {
parsed[key] = String(value)
.split(/\r?\n/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
return parsed;
}
module.exports = { module.exports = {
activate, activate,
deactivate deactivate

View File

@ -1,6 +1,7 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { const {
applyFormUpdates,
applyScalarUpdates, applyScalarUpdates,
parseSchemaContent, parseSchemaContent,
parseTopLevelYaml, parseTopLevelYaml,
@ -109,3 +110,31 @@ test("applyScalarUpdates should update top-level scalars and append new keys", (
assert.match(updated, /^hp: 25$/mu); assert.match(updated, /^hp: 25$/mu);
assert.match(updated, /^ - 1$/mu); assert.match(updated, /^ - 1$/mu);
}); });
test("applyFormUpdates should replace top-level scalar arrays and preserve unrelated content", () => {
const updated = applyFormUpdates(
[
"id: 1",
"name: Slime",
"dropItems:",
" - potion",
" - slime_gel",
"reward:",
" gold: 10"
].join("\n"),
{
scalars: {
name: "Goblin"
},
arrays: {
dropItems: ["bomb", "hi potion"]
}
});
assert.match(updated, /^name: Goblin$/mu);
assert.match(updated, /^dropItems:$/mu);
assert.match(updated, /^ - bomb$/mu);
assert.match(updated, /^ - hi potion$/mu);
assert.match(updated, /^reward:$/mu);
assert.match(updated, /^ gold: 10$/mu);
});