Add prototype gameplay loop with character/battlefield assets and server game APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-20 18:50:00 +08:00
co-authored by Cursor
parent 79369e4473
commit 6c64210331
833 changed files with 998079 additions and 1042 deletions
@@ -255,9 +255,45 @@ namespace AIGame.EditorTools
Fail(vr, "events 节点不存在: " + n);
}
}
ValidateNineSliceControls(root, nodes, vr);
return vr;
}
private static void ValidateNineSliceControls(JObject root, JArray nodes, ValidationResult vr)
{
var checkedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (JToken node in nodes)
{
foreach (JToken comp in (node["components"] as JArray) ?? new JArray())
{
string type = (string)comp["type"];
string[] fields = type == "Image" ? new[] { "sprite" }
: type == "Button" ? new[] { "spriteHighlighted", "spritePressed", "spriteDisabled" }
: Array.Empty<string>();
foreach (string field in fields)
{
string name = (string)comp[field];
if (string.IsNullOrEmpty(name)) continue;
string path = (string)root["assets"]?["sprites"]?[name]
?? TextureRoot + "/Common/" + name + ".png";
if (!UiSpriteNineSliceRules.IsStandardControl(path)) continue;
string imageType = (string)comp["imageType"];
if (type == "Image" && !string.IsNullOrEmpty(imageType)
&& !string.Equals(imageType, "Sliced", StringComparison.OrdinalIgnoreCase))
Fail(vr, "标准控件图必须使用 imageType: Sliced: " + (string)node["name"] + " / " + name);
if (!checkedPaths.Add(path)) continue;
Sprite sprite = AssetDatabase.LoadAssetAtPath<Sprite>(path);
if (sprite == null)
Fail(vr, "标准控件 Sprite 缺失: " + path);
else if (sprite.border.x <= 0 || sprite.border.y <= 0 || sprite.border.z <= 0 || sprite.border.w <= 0
|| sprite.border.x + sprite.border.z >= sprite.rect.width
|| sprite.border.y + sprite.border.w >= sprite.rect.height)
Fail(vr, "标准控件 Sprite 的九宫格 Border 无效,请执行 Tools/UI/Apply Standard Control Nine-Slice: " + path);
}
}
}
}
public static string BuildFromJsonFile(string jsonPath)
{
ValidationResult vr = Validate(jsonPath);
@@ -531,6 +567,9 @@ namespace AIGame.EditorTools
Sprite sprite = ResolveSprite(spriteName, fileName);
img.sprite = sprite;
string imageType = (string)comp["imageType"];
if (string.IsNullOrEmpty(imageType) && sprite != null
&& UiSpriteNineSliceRules.IsStandardControl(AssetDatabase.GetAssetPath(sprite)))
imageType = "Sliced";
if (string.Equals(imageType, "Sliced", StringComparison.OrdinalIgnoreCase))
{
img.type = Image.Type.Sliced;
@@ -569,6 +608,16 @@ namespace AIGame.EditorTools
Button btn = GetOrAdd<Button>(go);
Image target = go.GetComponent<Image>();
if (target != null) btn.targetGraphic = target;
string targetGraphicName = (string)comp["targetGraphic"];
if (!string.IsNullOrEmpty(targetGraphicName))
{
deferred.Add(() =>
{
if (!map.TryGetValue(targetGraphicName, out GameObject graphicGo) || graphicGo.GetComponent<Graphic>() == null)
throw new InvalidOperationException("Button targetGraphic not found: " + targetGraphicName);
btn.targetGraphic = graphicGo.GetComponent<Graphic>();
});
}
if (string.Equals((string)comp["transition"], "SpriteSwap", StringComparison.OrdinalIgnoreCase))
{
btn.transition = Selectable.Transition.SpriteSwap;
@@ -581,6 +630,14 @@ namespace AIGame.EditorTools
else
{
btn.transition = Selectable.Transition.ColorTint;
ColorBlock colors = btn.colors;
colors.normalColor = ParseColor((string)comp["normalColor"], colors.normalColor);
colors.highlightedColor = ParseColor((string)comp["highlightedColor"], colors.highlightedColor);
colors.pressedColor = ParseColor((string)comp["pressedColor"], colors.pressedColor);
colors.selectedColor = ParseColor((string)comp["selectedColor"], colors.selectedColor);
colors.disabledColor = ParseColor((string)comp["disabledColor"], colors.disabledColor);
if (comp["fadeDuration"] != null) colors.fadeDuration = (float)comp["fadeDuration"];
btn.colors = colors;
}
// onClick 仅作事件标记,不在 Prefab 写死回调
break;
@@ -0,0 +1,74 @@
#if UNITY_EDITOR
using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using AOT.Boot;
using UnityEditor;
using UnityEngine;
namespace AIGame.EditorTools
{
/// <summary>仅在 Boot 配置为本机 AIServer 时,进入 Play Mode 前启动已有服务。</summary>
[InitializeOnLoad]
public static class LocalServerAutoStart
{
[Serializable] class Health { public bool ok; public string service; }
static LocalServerAutoStart()
{
EditorApplication.playModeStateChanged += state =>
{
if (state != PlayModeStateChange.ExitingEditMode) return;
var boot = UnityEngine.Object.FindObjectOfType<BootDriver>();
if (boot == null) return;
var settings = new SerializedObject(boot).FindProperty("settings");
if (settings.FindPropertyRelative("HttpBase").stringValue.TrimEnd('/') != "http://127.0.0.1:8080") return;
try { UnityEngine.Debug.Log("[LocalAIServer] " + StartLocal()); }
catch (Exception e) { UnityEngine.Debug.LogWarning("[LocalAIServer] " + e.Message); }
};
}
static bool IsHealthy()
{
try
{
var request = (HttpWebRequest)WebRequest.Create("http://127.0.0.1:8080/health");
request.Timeout = 250; request.ReadWriteTimeout = 250; request.Proxy = null;
using (var response = request.GetResponse())
using (var reader = new StreamReader(response.GetResponseStream()))
{
var health = JsonUtility.FromJson<Health>(reader.ReadToEnd());
return health != null && health.ok && health.service == "aiserver";
}
}
catch { return false; }
}
[MenuItem("AIGame/Boot/Server/Start Local AIServer")]
public static string StartLocal()
{
if (IsHealthy()) return "already healthy at 127.0.0.1:8080";
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
string exe = Path.Combine(repo, "AIServer/build/Release/aiserver.exe");
if (!File.Exists(exe)) throw new FileNotFoundException("Build AIServer first: cmake --build AIServer/build --config Release", exe);
var info = new ProcessStartInfo(exe)
{
Arguments = "--host 127.0.0.1 --port 8080 --daemon --db \"" + Path.Combine(repo, "AIServer/data/aiserver.db") + "\"",
WorkingDirectory = Path.Combine(repo, "AIServer"),
UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden
};
using (var process = Process.Start(info))
{
for (int i = 0; i < 15; i++)
{
System.Threading.Thread.Sleep(100);
if (IsHealthy()) return "started at 127.0.0.1:8080";
if (process.HasExited) throw new Exception("Local AIServer exited; check port 8080 and the database path.");
}
}
throw new Exception("Local AIServer did not become healthy; theme preview remains available without login.");
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c394878c52f333f40b441423b6c4cc27
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
#if UNITY_EDITOR
using System.Linq;
using UnityEditor;
using UnityEngine;
using YooAsset.Editor;
namespace AIGame.EditorTools
{
/// <summary>把 Candy UI Prefab 收进 Art;图片/字体通过 Prefab 依赖进入资源包。</summary>
public static class SetupUiBundleCollector
{
[MenuItem("Tools/UI/Setup UI Bundle Collector")]
public static string Setup()
{
var package = BundleCollectorSettingData.Setting.Packages.FirstOrDefault(p => p.PackageName == "Art");
if (package == null) throw new System.InvalidOperationException("Set up the Art package before UI collection.");
var group = package.Groups.FirstOrDefault(g => g.GroupName == "UI");
if (group == null) group = BundleCollectorSettingData.CreateGroup(package, "UI");
group.GroupDesc = "Candy UI";
group.AssetTags = "ui";
group.ActiveRuleName = nameof(EnableGroup);
BundleCollectorSettingData.ModifyGroup(package, group);
const string path = "Assets/Bundles/UI/Prefab";
var collector = group.Collectors.FirstOrDefault(c => c.CollectPath == path);
if (collector == null)
{
collector = new BundleCollector
{
CollectPath = path,
CollectorGUID = AssetDatabase.AssetPathToGUID(path),
CollectorType = ECollectorType.MainAssetCollector,
AddressRuleName = nameof(AddressByFileName),
PackRuleName = nameof(PackSeparately),
FilterRuleName = nameof(CollectPrefab),
AssetTags = "ui"
};
BundleCollectorSettingData.CreateCollector(group, collector);
}
package.CheckConfigError();
BundleCollectorSettingData.SaveFile();
return "Art/UI collects Candy UI by prefab name, including sprite/font dependencies.";
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ff5ceeb3b52f7042b92bd3bfc260c05
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,121 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace AIGame.EditorTools
{
/// <summary>Common 标准控件的九宫格导入规则,基于 Doc/UI_Normal_Set.md。</summary>
public static class UiSpriteNineSliceRules
{
private const string CommonRoot = "Assets/Bundles/UI/Texture/Common/";
// W,H,L,R,T,B:设计尺寸与边界;实际切图按尺寸换算,并包含 2px 透明留边。
private static readonly Dictionary<string, int[]> Rules = new Dictionary<string, int[]>(StringComparer.OrdinalIgnoreCase)
{
{ "ui_panel_main", new[] { 480, 256, 40, 40, 40, 40 } },
{ "ui_panel_popup", new[] { 360, 240, 36, 36, 36, 36 } },
{ "ui_panel_content", new[] { 320, 200, 28, 28, 28, 28 } },
{ "ui_panel_header", new[] { 400, 80, 32, 32, 24, 24 } },
{ "btn_primary_normal", new[] { 224, 88, 32, 32, 24, 24 } },
{ "btn_primary_pressed", new[] { 224, 88, 32, 32, 24, 24 } },
{ "btn_primary_disabled", new[] { 224, 88, 32, 32, 24, 24 } },
{ "btn_secondary_normal", new[] { 224, 88, 32, 32, 24, 24 } },
{ "btn_secondary_pressed", new[] { 224, 88, 32, 32, 24, 24 } },
{ "btn_small_normal", new[] { 160, 64, 24, 24, 18, 18 } },
{ "btn_small_pressed", new[] { 160, 64, 24, 24, 18, 18 } },
{ "scrollbar_track_v", new[] { 28, 160, 8, 8, 16, 16 } },
{ "scrollbar_thumb_v", new[] { 28, 80, 8, 8, 16, 16 } },
{ "input_bg_normal", new[] { 360, 72, 28, 28, 20, 20 } },
{ "input_bg_focus", new[] { 360, 72, 28, 28, 20, 20 } },
{ "ui_list_item_normal", new[] { 320, 72, 20, 20, 16, 16 } },
{ "ui_list_item_selected", new[] { 320, 72, 20, 20, 16, 16 } },
{ "tab_active", new[] { 200, 72, 28, 28, 20, 20 } },
{ "tab_inactive", new[] { 200, 72, 28, 28, 20, 20 } },
{ "progress_bg", new[] { 400, 32, 16, 16, 10, 10 } },
{ "progress_fill", new[] { 400, 32, 16, 16, 10, 10 } },
{ "slider_track", new[] { 320, 28, 14, 14, 8, 8 } },
{ "slider_fill", new[] { 320, 28, 14, 14, 8, 8 } },
{ "toast_bg", new[] { 280, 80, 28, 28, 20, 20 } },
{ "toggle_bg_off", new[] { 112, 56, 28, 28, 18, 18 } },
{ "toggle_bg_on", new[] { 112, 56, 28, 28, 18, 18 } }
};
public static bool IsStandardControl(string path)
{
if (string.IsNullOrEmpty(path)) return false;
path = path.Replace('\\', '/');
return path.StartsWith(CommonRoot, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Path.GetDirectoryName(path)?.Replace('\\', '/'), CommonRoot.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)
&& Rules.ContainsKey(Path.GetFileNameWithoutExtension(path));
}
public static Vector4 GetBorder(string path, int width, int height)
{
int[] rule = Rules[Path.GetFileNameWithoutExtension(path)];
float sx = Mathf.Max(1, width - 4) / (float)rule[0];
float sy = Mathf.Max(1, height - 4) / (float)rule[1];
// Unity Vector4 顺序为 L,B,R,T,与文档 L,R,T,B 不同。
float maxX = Mathf.Max(1, (width - 1) / 2);
float maxY = Mathf.Max(1, (height - 1) / 2);
return new Vector4(
Mathf.Clamp(Mathf.Round(rule[2] * sx) + 2, 1, maxX),
Mathf.Clamp(Mathf.Round(rule[5] * sy) + 2, 1, maxY),
Mathf.Clamp(Mathf.Round(rule[3] * sx) + 2, 1, maxX),
Mathf.Clamp(Mathf.Round(rule[4] * sy) + 2, 1, maxY));
}
public static bool Configure(TextureImporter importer)
{
if (!IsStandardControl(importer.assetPath)) return false;
importer.GetSourceTextureWidthAndHeight(out int width, out int height);
Vector4 border = GetBorder(importer.assetPath, width, height);
var settings = new TextureImporterSettings();
importer.ReadTextureSettings(settings);
bool changed = importer.textureType != TextureImporterType.Sprite
|| importer.spriteImportMode != SpriteImportMode.Single
|| settings.spriteMeshType != SpriteMeshType.FullRect
|| importer.spriteBorder != border;
if (!changed) return false;
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Single;
importer.ReadTextureSettings(settings);
settings.spriteMeshType = SpriteMeshType.FullRect;
importer.SetTextureSettings(settings);
importer.spriteBorder = border;
return true;
}
[MenuItem("Tools/UI/Apply Standard Control Nine-Slice")]
public static void ApplyFromMenu()
{
Debug.Log("[UIGen] 九宫格更新: " + ApplyExisting() + " 张标准控件图。");
}
public static int ApplyExisting()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
throw new InvalidOperationException("Apply UI sprite borders in Edit Mode.");
int changed = 0;
foreach (string name in Rules.Keys)
{
string path = CommonRoot + name + ".png";
var importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer == null || !Configure(importer)) continue;
importer.SaveAndReimport();
changed++;
}
return changed;
}
}
public sealed class UiSpriteNineSlicePostprocessor : AssetPostprocessor
{
private void OnPreprocessTexture()
{
UiSpriteNineSliceRules.Configure((TextureImporter)assetImporter);
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 47e634eecc0295440a1fa7f8a0fe2423
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: