mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-04-02 20:09:00 +08:00
feat(game): 添加游戏内容配置系统和VS Code插件支持
- 实现YAML配置文件管理和JSON Schema验证功能 - 提供运行时只读查询和Source Generator代码生成功能 - 开发VS Code插件实现配置浏览、校验和轻量表单编辑 - 支持开发期热重载和配置变更自动刷新机制 - 集成诊断功能提供配置文件错误提示和修复建议
This commit is contained in:
parent
e8d0ea2daf
commit
0c662ced2a
@ -144,7 +144,7 @@ var hotReload = loader.EnableHotReload(
|
||||
- 打开 raw YAML 文件
|
||||
- 打开匹配的 schema 文件
|
||||
- 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验
|
||||
- 对顶层标量字段提供轻量表单入口
|
||||
- 对顶层标量字段和顶层标量数组提供轻量表单入口
|
||||
|
||||
当前仍建议把复杂数组、嵌套对象和批量修改放在 raw YAML 中完成。
|
||||
|
||||
@ -154,6 +154,6 @@ var hotReload = loader.EnableHotReload(
|
||||
|
||||
- 跨表引用校验
|
||||
- 更完整的 JSON Schema 支持
|
||||
- 更强的 VS Code 表单编辑器
|
||||
- 更强的 VS Code 嵌套对象与复杂数组编辑器
|
||||
|
||||
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。
|
||||
|
||||
@ -8,7 +8,7 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
|
||||
- Open raw YAML files
|
||||
- Open matching schema files from `schemas/`
|
||||
- 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
|
||||
|
||||
@ -32,8 +32,8 @@ node --test ./test/*.test.js
|
||||
|
||||
- Multi-root workspaces use the first workspace folder
|
||||
- Validation only covers a minimal subset of JSON Schema
|
||||
- Form editing currently supports top-level scalar fields only
|
||||
- Arrays and nested objects should still be edited in raw YAML
|
||||
- Form editing currently supports top-level scalar fields and top-level scalar arrays
|
||||
- Nested objects and complex arrays should still be edited in raw YAML
|
||||
|
||||
## Workspace Settings
|
||||
|
||||
|
||||
@ -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 {Record<string, string>} updates Updated scalar values.
|
||||
* @returns {string} Updated YAML content.
|
||||
*/
|
||||
function applyScalarUpdates(originalYaml, updates) {
|
||||
const lines = originalYaml.split(/\r?\n/u);
|
||||
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");
|
||||
return applyFormUpdates(originalYaml, {scalars: updates});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -329,8 +370,90 @@ function parseTopLevelArray(childLines) {
|
||||
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 = {
|
||||
applyFormUpdates,
|
||||
applyScalarUpdates,
|
||||
findTopLevelBlocks,
|
||||
formatYamlScalar,
|
||||
isScalarCompatible,
|
||||
parseSchemaContent,
|
||||
|
||||
@ -2,7 +2,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const vscode = require("vscode");
|
||||
const {
|
||||
applyScalarUpdates,
|
||||
applyFormUpdates,
|
||||
parseSchemaContent,
|
||||
parseTopLevelYaml,
|
||||
unquoteScalar,
|
||||
@ -247,9 +247,9 @@ async function openSchemaFile(item) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a lightweight form preview for top-level scalar fields.
|
||||
* The editor intentionally edits only simple scalar keys and keeps raw YAML as
|
||||
* the escape hatch for arrays, nested objects, and advanced changes.
|
||||
* Open a lightweight form preview for top-level scalar fields and scalar
|
||||
* arrays. Nested objects and more complex array shapes still use raw YAML as
|
||||
* the escape hatch.
|
||||
*
|
||||
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
|
||||
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
|
||||
@ -279,7 +279,10 @@ async function openFormPreview(item, diagnostics) {
|
||||
|
||||
panel.webview.onDidReceiveMessage(async (message) => {
|
||||
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");
|
||||
const document = await vscode.workspace.openTextDocument(configUri);
|
||||
await document.save();
|
||||
@ -409,7 +412,7 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
|
||||
* @returns {string} HTML string.
|
||||
*/
|
||||
function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
const fields = Array.from(parsedYaml.entries.entries())
|
||||
const scalarFields = Array.from(parsedYaml.entries.entries())
|
||||
.filter(([, entry]) => entry.kind === "scalar")
|
||||
.map(([key, entry]) => {
|
||||
const escapedKey = escapeHtml(key);
|
||||
@ -424,13 +427,48 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
})
|
||||
.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
|
||||
? `Schema: ${escapeHtml(schemaInfo.schemaPath)}`
|
||||
: `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`;
|
||||
|
||||
const emptyState = fields.length > 0
|
||||
? fields
|
||||
: "<p>No editable top-level scalar fields were detected. Use raw YAML for nested objects or arrays.</p>";
|
||||
const editableContent = [scalarFields, arrayFields].filter((content) => content.length > 0).join("\n");
|
||||
const unsupportedSection = unsupportedFields.length > 0
|
||||
? `<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>
|
||||
<html lang="en">
|
||||
@ -477,6 +515,22 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
background: var(--vscode-input-background);
|
||||
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 {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
@ -486,11 +540,20 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
color: var(--vscode-badge-foreground);
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<button id="save">Save Scalars</button>
|
||||
<button id="save">Save Form</button>
|
||||
<button id="openRaw">Open Raw YAML</button>
|
||||
</div>
|
||||
<div class="meta">
|
||||
@ -501,11 +564,15 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
document.getElementById("save").addEventListener("click", () => {
|
||||
const values = {};
|
||||
const scalars = {};
|
||||
const arrays = {};
|
||||
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", () => {
|
||||
vscode.postMessage({ type: "openRaw" });
|
||||
@ -638,6 +705,25 @@ function escapeHtml(value) {
|
||||
.replace(/'/gu, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
activate,
|
||||
deactivate
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
applyFormUpdates,
|
||||
applyScalarUpdates,
|
||||
parseSchemaContent,
|
||||
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, /^ - 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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user