初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
namespace PCL.Core.App.Cli;
|
||||
|
||||
public enum ArgumentValueKind
|
||||
{
|
||||
Bool,
|
||||
Decimal,
|
||||
Text,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace PCL.Core.App.Cli;
|
||||
|
||||
public class BoolArgument : CommandArgument<bool>
|
||||
{
|
||||
public override ArgumentValueKind ValueKind => ArgumentValueKind.Bool;
|
||||
|
||||
protected override bool ParseValueText()
|
||||
{
|
||||
var text = ValueText.ToLowerInvariant().Trim();
|
||||
return text is not ("0" or "false");
|
||||
}
|
||||
|
||||
public override bool TryCastValue<T>([NotNullWhen(true)] out T value)
|
||||
{
|
||||
if (base.TryCastValue(out value)) return true;
|
||||
var type = typeof(T);
|
||||
if (type != typeof(sbyte) &&
|
||||
type != typeof(byte) &&
|
||||
type != typeof(short) &&
|
||||
type != typeof(ushort) &&
|
||||
type != typeof(int) &&
|
||||
type != typeof(uint) &&
|
||||
type != typeof(long) &&
|
||||
type != typeof(ulong) &&
|
||||
type != typeof(nint) &&
|
||||
type != typeof(nuint)) return false;
|
||||
// magic code
|
||||
var v = Value;
|
||||
Unsafe.As<T, byte>(ref value) = Unsafe.As<bool, byte>(ref v);
|
||||
#pragma warning disable CS8762 // The analyzer sucks.
|
||||
return true;
|
||||
#pragma warning restore CS8762
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace PCL.Core.App.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// 无泛型的命令行参数模型
|
||||
/// </summary>
|
||||
/// <seealso cref="CommandArgument{TValue}"/>
|
||||
public abstract class CommandArgument
|
||||
{
|
||||
/// <summary>
|
||||
/// 参数键
|
||||
/// </summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 参数值文本
|
||||
/// </summary>
|
||||
public required string ValueText { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 参数值类型
|
||||
/// </summary>
|
||||
public abstract ArgumentValueKind ValueKind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 尝试以指定类型获取参数值
|
||||
/// </summary>
|
||||
/// <param name="value">参数值,若尝试失败则为该类型默认值</param>
|
||||
/// <typeparam name="T">参数值的类型</typeparam>
|
||||
/// <returns>是否成功,若类型不匹配则失败</returns>
|
||||
public abstract bool TryCastValue<T>([NotNullWhen(true)] out T? value);
|
||||
|
||||
public T? CastValue<T>()
|
||||
{
|
||||
var result = TryCastValue(out T? value);
|
||||
return result ? value : throw new InvalidCastException("Value type mismatch or cannot cast");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 命令行参数模型
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">参数值的类型</typeparam>
|
||||
public abstract class CommandArgument<TValue> : CommandArgument
|
||||
{
|
||||
/// <summary>
|
||||
/// 从参数值文本中解析参数类型
|
||||
/// </summary>
|
||||
/// <returns>对应类型的参数值</returns>
|
||||
protected abstract TValue ParseValueText();
|
||||
|
||||
private bool _isValueParsed = false;
|
||||
|
||||
/// <summary>
|
||||
/// 参数值
|
||||
/// </summary>
|
||||
public TValue Value
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_isValueParsed) return field;
|
||||
_isValueParsed = true;
|
||||
return field = ParseValueText();
|
||||
}
|
||||
protected init
|
||||
{
|
||||
field = value;
|
||||
_isValueParsed = true;
|
||||
}
|
||||
} = default!;
|
||||
|
||||
public override bool TryCastValue<T>([NotNullWhen(true)] out T value)
|
||||
{
|
||||
if (Value is T v)
|
||||
{
|
||||
value = v;
|
||||
return true;
|
||||
}
|
||||
value = default!;
|
||||
if (typeof(T) == typeof(string))
|
||||
{
|
||||
Unsafe.As<T, string>(ref value) = ValueText;
|
||||
#pragma warning disable CS8762 // The analyzer sucks.
|
||||
return true;
|
||||
#pragma warning restore CS8762
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.App.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// 命令行模型
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(CommandLineJsonConverter))]
|
||||
public class CommandLine
|
||||
{
|
||||
/// <summary>
|
||||
/// 命令文本
|
||||
/// </summary>
|
||||
public required string CommandText { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 子命令
|
||||
/// </summary>
|
||||
public CommandLine? Subcommand { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// 子命令文本
|
||||
/// </summary>
|
||||
public string? SubcommandText => Subcommand?.CommandText;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, CommandArgument> Arguments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 尝试获取参数值
|
||||
/// </summary>
|
||||
/// <param name="key">参数键</param>
|
||||
/// <param name="value">参数值,若获取失败则为对应类型默认值</param>
|
||||
/// <typeparam name="TValue">参数值的类型</typeparam>
|
||||
/// <returns>是否存在该键; 存在该键时值的类型是否匹配</returns>
|
||||
public (bool exists, bool isTypeMatch) TryGetArgumentValue<TValue>(string key, out TValue? value)
|
||||
{
|
||||
var exists = Arguments.TryGetValue(key, out var arg);
|
||||
var isTypeMatch = false;
|
||||
if (exists && (isTypeMatch = arg!.TryCastValue(out TValue? typedValue)))
|
||||
{
|
||||
value = typedValue;
|
||||
return (true, true);
|
||||
}
|
||||
value = default;
|
||||
return (exists, isTypeMatch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析参数数组,第一个元素会被视为主命令
|
||||
/// </summary>
|
||||
/// <param name="args">参数数组</param>
|
||||
/// <param name="subcommands">各级子命令列表</param>
|
||||
/// <returns>命令行模型实例</returns>
|
||||
public static CommandLine Parse(ReadOnlySpan<string> args, IEnumerable<SubcommandDefinition>? subcommands = null)
|
||||
{
|
||||
subcommands ??= [];
|
||||
SubcommandDefinition root = (args[0], subcommands);
|
||||
return CommandLineParser.Parse(args, root);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CommandText).Append(" [");
|
||||
if (Arguments.Count > 0) sb.AppendLine();
|
||||
foreach (var arg in Arguments.Values)
|
||||
{
|
||||
sb.Append(" --").Append(arg.Key);
|
||||
var value = arg.ValueKind switch
|
||||
{
|
||||
ArgumentValueKind.Bool => arg.CastValue<bool>() ? "true" : "false",
|
||||
ArgumentValueKind.Decimal => arg.CastValue<decimal>().ToString(CultureInfo.InvariantCulture),
|
||||
ArgumentValueKind.Text => arg.ValueText,
|
||||
_ => null
|
||||
};
|
||||
if (value is not null) sb.Append(": ").Append(value);
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.Append(']');
|
||||
if (Subcommand is not null) sb.AppendLine().Append("-> ").Append(Subcommand.ToString().Replace("\n", "\n "));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
file static class CommandLineParser
|
||||
{
|
||||
private static (CommandArgument, bool) _ParseArgument(string key, string possibleValueText)
|
||||
{
|
||||
if (key.StartsWith("--")) key = key[2..];
|
||||
if (possibleValueText.Length == 0 || possibleValueText.StartsWith("--"))
|
||||
return (new BoolArgument { Key = key, ValueText = string.Empty }, false);
|
||||
if (possibleValueText.ToLowerInvariant() is "true" or "false")
|
||||
return (new BoolArgument { Key = key, ValueText = possibleValueText }, true);
|
||||
if (decimal.TryParse(possibleValueText, out var d))
|
||||
return (new DecimalArgument { Key = key, ValueText = possibleValueText, Value = d }, true);
|
||||
return (new TextArgument { Key = key, ValueText = possibleValueText }, true);
|
||||
}
|
||||
|
||||
public static CommandLine Parse(ReadOnlySpan<string> args, SubcommandDefinition subcommands)
|
||||
{
|
||||
if (args.IsEmpty) throw new ArgumentException("The argument span must contain at least 1 element", nameof(args));
|
||||
var i = 1;
|
||||
var commandText = args[0];
|
||||
var argumentList = new Dictionary<string, CommandArgument>();
|
||||
CommandLine? subcommand = null;
|
||||
while (i < args.Length)
|
||||
{
|
||||
var currentText = args[i];
|
||||
if (subcommands.Contains(currentText))
|
||||
{
|
||||
subcommand = Parse(args[i..], subcommands.SubcommandMap[currentText]);
|
||||
break;
|
||||
}
|
||||
var (commandArgument, hasValueText) = _ParseArgument(currentText,
|
||||
(i == args.Length - 1) || (subcommands.Contains(args[i + 1])) ? "" : args[i + 1]);
|
||||
argumentList[commandArgument.Key] = commandArgument;
|
||||
i += hasValueText ? 2 : 1;
|
||||
}
|
||||
return new CommandLine
|
||||
{
|
||||
CommandText = commandText,
|
||||
Arguments = argumentList.AsReadOnly(),
|
||||
Subcommand = subcommand
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于 <see cref="CommandLine"/> 的 JSON 转换器
|
||||
/// </summary>
|
||||
// Generated by gpt-5.3-codex (20260218)
|
||||
public sealed class CommandLineJsonConverter : JsonConverter<CommandLine>
|
||||
{
|
||||
public override CommandLine? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null) return null;
|
||||
if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException("Expected object for CommandLine.");
|
||||
|
||||
string? commandText = null;
|
||||
CommandLine? subcommand = null;
|
||||
var arguments = new Dictionary<string, CommandArgument>();
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject) break;
|
||||
if (reader.TokenType != JsonTokenType.PropertyName) throw new JsonException("Expected property name.");
|
||||
|
||||
var propName = reader.GetString();
|
||||
if (!reader.Read()) throw new JsonException("Unexpected end of json.");
|
||||
|
||||
switch (propName)
|
||||
{
|
||||
case "cmd":
|
||||
commandText = reader.GetString() ?? throw new JsonException("cmd cannot be null.");
|
||||
break;
|
||||
case "sub":
|
||||
subcommand = JsonSerializer.Deserialize<CommandLine>(ref reader, options);
|
||||
break;
|
||||
case "args":
|
||||
_ReadArguments(ref reader, arguments);
|
||||
break;
|
||||
default:
|
||||
// 忽略未知字段,提升前向兼容
|
||||
reader.Skip();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(commandText)) throw new JsonException("Missing required property: cmd.");
|
||||
|
||||
return new CommandLine
|
||||
{
|
||||
CommandText = commandText,
|
||||
Arguments = arguments.AsReadOnly(),
|
||||
Subcommand = subcommand
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, CommandLine value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("cmd", value.CommandText);
|
||||
if (value.Arguments.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("args");
|
||||
writer.WriteStartArray();
|
||||
foreach (var arg in value.Arguments.Values)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", arg.Key);
|
||||
writer.WriteNumber("t", (int)arg.ValueKind);
|
||||
writer.WriteString("v", arg.ValueText);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
if (value.Subcommand is not null)
|
||||
{
|
||||
writer.WritePropertyName("sub");
|
||||
JsonSerializer.Serialize(writer, value.Subcommand, options);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void _ReadArguments(ref Utf8JsonReader reader, Dictionary<string, CommandArgument> target)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartArray) throw new JsonException("args must be an array.");
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndArray) return;
|
||||
if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException("Argument item must be an object.");
|
||||
|
||||
string? key = null;
|
||||
ArgumentValueKind? kind = null;
|
||||
string? valueText = null;
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject) break;
|
||||
if (reader.TokenType != JsonTokenType.PropertyName) throw new JsonException("Expected argument property name.");
|
||||
|
||||
var name = reader.GetString();
|
||||
if (!reader.Read()) throw new JsonException("Unexpected end of json.");
|
||||
|
||||
switch (name)
|
||||
{
|
||||
case "k":
|
||||
key = reader.GetString();
|
||||
break;
|
||||
case "t":
|
||||
kind = (ArgumentValueKind)reader.GetInt32();
|
||||
break;
|
||||
case "v":
|
||||
valueText = reader.GetString() ?? string.Empty;
|
||||
break;
|
||||
default:
|
||||
reader.Skip();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(key)) throw new JsonException("Argument missing key(k).");
|
||||
if (kind is null) throw new JsonException("Argument missing type(t).");
|
||||
valueText ??= string.Empty;
|
||||
|
||||
CommandArgument arg = kind.Value switch
|
||||
{
|
||||
ArgumentValueKind.Bool => new BoolArgument { Key = key, ValueText = valueText },
|
||||
ArgumentValueKind.Decimal => new DecimalArgument { Key = key, ValueText = valueText },
|
||||
ArgumentValueKind.Text => new TextArgument { Key = key, ValueText = valueText },
|
||||
_ => throw new JsonException($"Unsupported argument type: {(int)kind.Value}")
|
||||
};
|
||||
target[arg.Key] = arg;
|
||||
}
|
||||
throw new JsonException("args array is not closed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace PCL.Core.App.Cli;
|
||||
|
||||
public class DecimalArgument : CommandArgument<decimal>
|
||||
{
|
||||
public override ArgumentValueKind ValueKind => ArgumentValueKind.Decimal;
|
||||
|
||||
protected override decimal ParseValueText() => decimal.Parse(ValueText);
|
||||
|
||||
public new decimal Value
|
||||
{
|
||||
get => base.Value;
|
||||
init => base.Value = value;
|
||||
}
|
||||
|
||||
public override bool TryCastValue<T>([NotNullWhen(true)] out T value)
|
||||
{
|
||||
if (base.TryCastValue(out value)) return true;
|
||||
var type = typeof(T);
|
||||
try
|
||||
{
|
||||
if (type == typeof(int)) Unsafe.As<T, int>(ref value) = Convert.ToInt32(Value);
|
||||
else if (type == typeof(long)) Unsafe.As<T, long>(ref value) = Convert.ToInt64(Value);
|
||||
else if (type == typeof(double)) Unsafe.As<T, double>(ref value) = Convert.ToDouble(Value);
|
||||
else if (type == typeof(float)) Unsafe.As<T, float>(ref value) = Convert.ToSingle(Value);
|
||||
else if (type == typeof(short)) Unsafe.As<T, short>(ref value) = Convert.ToInt16(Value);
|
||||
else if (type == typeof(sbyte)) Unsafe.As<T, sbyte>(ref value) = Convert.ToSByte(Value);
|
||||
else if (type == typeof(ulong)) Unsafe.As<T, ulong>(ref value) = Convert.ToUInt64(Value);
|
||||
else if (type == typeof(uint)) Unsafe.As<T, uint>(ref value) = Convert.ToUInt32(Value);
|
||||
else if (type == typeof(ushort)) Unsafe.As<T, ushort>(ref value) = Convert.ToUInt16(Value);
|
||||
else if (type == typeof(byte)) Unsafe.As<T, byte>(ref value) = Convert.ToByte(Value);
|
||||
else if (type == typeof(nint)) Unsafe.As<T, nint>(ref value) = checked((nint)Convert.ToInt64(Value));
|
||||
else if (type == typeof(nuint)) Unsafe.As<T, nuint>(ref value) = checked((nuint)Convert.ToUInt64(Value));
|
||||
else return false;
|
||||
#pragma warning disable CS8762 // The analyzer sucks.
|
||||
return true;
|
||||
#pragma warning restore CS8762
|
||||
}
|
||||
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.App.Cli;
|
||||
|
||||
public class SubcommandDefinition
|
||||
{
|
||||
public required string CommandText { get; init; }
|
||||
|
||||
public required IEnumerable<SubcommandDefinition> Subcommands { private get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, SubcommandDefinition> SubcommandMap
|
||||
{
|
||||
get
|
||||
{
|
||||
if (field is not null) return field;
|
||||
var map = new Dictionary<string, SubcommandDefinition>();
|
||||
foreach (var c in Subcommands) map[c.CommandText] = c;
|
||||
return field = map.AsReadOnly();
|
||||
}
|
||||
} = null!;
|
||||
|
||||
public bool Contains(string subcommandText)
|
||||
{
|
||||
return SubcommandMap.ContainsKey(subcommandText);
|
||||
}
|
||||
|
||||
public static implicit operator SubcommandDefinition((string commandText, IEnumerable<SubcommandDefinition> subcommands) tuple)
|
||||
{
|
||||
return new SubcommandDefinition
|
||||
{
|
||||
CommandText = tuple.commandText,
|
||||
Subcommands = tuple.subcommands
|
||||
};
|
||||
}
|
||||
|
||||
public static implicit operator SubcommandDefinition(string commandText)
|
||||
{
|
||||
return new SubcommandDefinition
|
||||
{
|
||||
CommandText = commandText,
|
||||
Subcommands = []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PCL.Core.App.Cli;
|
||||
|
||||
public class TextArgument : CommandArgument<string>
|
||||
{
|
||||
public override ArgumentValueKind ValueKind => ArgumentValueKind.Text;
|
||||
|
||||
protected override string ParseValueText() => ValueText;
|
||||
}
|
||||
Reference in New Issue
Block a user