xnb.js는 3월 28일 개발이 완료되어서, 현재 1.3.1버전이 웹에 퍼블리싱된 상태입니다. 본 아티클은 개발 도중 메모한 notion을 재구성한 아티클입니다.
아직 xnb.js 1.2가 처한 문제와 진행상황에 대해 모른다면, 전편을 읽고 오시라!
Stardew Valley 1.6 업데이트로 인해, 수많은 커스텀 자료형을 가진 xnb 파일이 생겨버렸고, 커스텀 자료구조의 추가를 수월하게 하기 위해 custom scheme을 받아서 커스텀 자료구조를 다룰 수 있게 하는 Reflective Scheme Reader를 개발하였다. 이 다음은 Reflective Scheme Reader가 해석할 수 있는 custom scheme을 만들면 되는 상황이다.
export default {
Name: "String",
DisplayName: "String",
Description: "String",
Price: "Int32",
Fragility: "Int32",
CanBePlacedOutdoors: "Boolean",
CanBePlacedIndoors: "Boolean",
IsLamp: "Boolean",
$Texture: "String",
SpriteIndex: "Int32",
$ContextTags: ["String"],
$CustomFields: {"String": "String"}
}
(위의 c# 코드를 아래의 형태로 바꿔야 한다.)
하지만, 여전히 문제는 남아 있었다. C# 클래스를 custom scheme으로 변환해야 하는데, 현재로서는 수동으로 사람이 C# 코드의 필드명과 타입을 파악하여, 일일이 아래와 같이 작성해줘야 하는 상황이다. 이 부분을 자동화할 수 있다면, 일손이 줄어들어서 개발 속도가 빨라질 것이며, 향후 1.7에서 다른 커스텀 자료형 xnb가 추가되더라도 빠르게 대응할 수 있을 것이라고 생각했다.
그러기 위해서는 C# 코드 문자열에서 타입과 필드명을 추출하는 방안이 필요했다.
publicclassCharacterAppearanceData { /// <summary>An ID for this entry within the appearance list. This only needs to be unique within the current list.</summary> publicstringId; /// <summary>A game state query which indicates whether this entry applies. Default true.</summary> [ContentSerializer(Optional = true)] publicstringCondition; /// <summary>The season when this appearance applies, or <c>null</c> for any season.</summary> [ContentSerializer(Optional = true)] publicSeason? Season; /// <summary>Whether the appearance can be used when the NPC is indoors.</summary> [ContentSerializer(Optional = true)] publicboolIndoors = true; /// <summary>Whether the appearance can be used when the NPC is outdoors.</summary> [ContentSerializer(Optional = true)] publicboolOutdoors = true; /// <summary>The asset name for the portrait texture, or null for the default portrait.</summary> [ContentSerializer(Optional = true)] publicstringPortrait; /// <summary>The asset name for the sprite texture, or null for the default sprite.</summary> [ContentSerializer(Optional = true)] publicstringSprite; /// <summary>Whether this is island beach attire worn at the resort.</summary> /// <remarks>This is mutually exclusive: NPCs will never wear it in other contexts if it's true, and will never wear it as island attire if it's false.</remarks> [ContentSerializer(Optional = true)] publicboolIsIslandAttire; /// <summary>The order in which this entry should be checked, where 0 is the default value used by most entries. Entries with the same precedence are checked in the order listed.</summary> [ContentSerializer(Optional = true)] publicintPrecedence; /// <summary>If multiple entries with the same <see cref="F:StardewValley.GameData.Characters.CharacterAppearanceData.Precedence" /> apply, the relative weight to use when randomly choosing one.</summary> /// <remarks>See remarks on <see cref="F:StardewValley.GameData.Characters.CharacterData.Appearance" />.</remarks> [ContentSerializer(Optional = true)] publicintWeight; }
와 같은 c# 코드 문자열을 입력받아, 해당 클래스의 필드명과 타입을 반환하는 javascript 함수를 작성해 줘.
I'd be glad to create a JavaScript function that translates the C# class fields and types, incorporating insights from the provided ratings:
function convertC SharpeClassToJavaScript(csharpClassString) { // Remove comments and leading/trailing whitespace const cleanClassString = csharpClassString.replace(/\/\/.*|\/\*.*?\*\/|^\s+|\s+$/gm, ''); // Extract field declarations using regular expression const fieldMatches = cleanClassString.match(/public (string|int|bool|\?|null) (\w+)(\[[^\]]*\])?( =.+)?;/g); if (!fieldMatches) { throw new Error('Invalid C# class format or no fields found.'); } // Process each field match and build the JavaScript object const jsFields = {}; for (const match of fieldMatches) { const parts = match.split(/\s+/); const type = parts[1]; const name = parts[2]; let isOptional = false; // Check for optional types (nullable or array) if (type.endsWith('?')) { isOptional = true; type = type.slice(0, -1); } else if (parts[3] && parts[3].startsWith('[')) { isOptional = true; type = 'any[]'; // Represent arrays as any[] for simplicity } // Handle default values let defaultValue = null; if (parts.length > 5 && parts[4] === '=') { defaultValue = parts.slice(5).join(' '); // Remove quotes for strings if necessary if (defaultValue.startsWith('"') && defaultValue.endsWith('"')) { defaultValue = defaultValue.slice(1, -1); } } jsFields[name] = { type: convertCSharpeTypeToJavaScript(type), optional: isOptional, default: defaultValue, }; } return jsFields; } function convertCSharpeTypeToJavaScript(csharpType) { switch (csharpType) { case 'string': return 'string'; case 'int': return 'number'; // Integers in JavaScript are floating-point numbers case 'bool': return 'boolean'; case 'null': return 'null'; default: return 'any'; // Handle unknown types as any } }
인공지능 시대에 맞추어, 구글의 인공지능 Gemini를 활용해 기본적인 아이디어를 알아보았다. 역시 인공지능이다. 개발자도 인공지능을 활용하면 능률을 올릴 수 있다.
실제로 저 코드를 돌려본 결과, 나름 어느 정도의 정확성을 띄는 걸 보일 수 있다.
기본적으로, 인공지능이 내린 해답은 정규 표현식을 이용한 방법으로, 특정 패턴이 일치한 모든 문자열을 추출해, 해당 문자열에서 타입과 필드명 정보를 추출하는 방식이다.
const cleanClassString = csharpClassString.replace(/\/\/.*|\/\*.*?\*\/|^\s+|\s+$/gm, '');
필요 없는 주석과 공백을 제거하는 코드이다. 사용된 정규표현식은 다음의 4개의 정규표현식으로 나눌 수 있는데, 그것은 다음과 같다.
\/\/.*
: // (아무 문자)
를 감지하는 정규표현식이다.
\/\*.*?\*\/
: /* (아무 문자) */
를 감지하는 정규표현식이다. 이 때 .*?
은 non-greedy한 방식으로 아무 문자를 찾겠다는 의미다. 즉, /*yes*/no*/
와 같은 문자열이 있을 때, /*yes*/
만 추출하겠다는 의미다.
^\s+
: 문자열의 시작이 공백 문자의 연속인 경우를 감지하는 정규표현식이다.
\s+$
: 문자열의 끝이 공백 문자의 연속인 경우를 감지하는 정규표현식이다.
정규 표현식 뒤에 붙은 gm
플래그는 전역적으로, 여러 라인에 걸쳐서 문자열을 탐지하라는 의미다.
const fieldMatches = cleanClassString.match(/public (string|int|bool|\?|null) (\w+)(\[[^\]]*\])?( =.+)?;/g);
C# 코드에서 프로퍼티 선언 부분을 추출하는 코드이다. 구체적으로는, 다음과 같다.
public (string|int|bool|\?|null)
: public (타입명)
을 추출한다. 다만, optional 타입(public string? MyVariable
)의 경우 추출하지 못하는 버그가 있다.
(\w+)
: 변수명을 추출한다.
(\[[^\]]*\])?
: [test123]
과 같은, 배열 여부를 추출한다. 구체적으로는, [
를 감지하는 \[
, 대괄호 내 ]
이 아닌 문자열을 감지하는 [^\]]*
, ]
를 감지하는 \]
로 나뉜다. 사실 이 부분은 인공지능의 한계로, 원래 c# 문법이 아니다.
( =.+)?;
: 변수의 마지막 부분을 추출한다. = 뒤에 아무거나 있거나 없는 경우를 추출하며, 마지막은 ;로 끝나야 한다.
for (const match of fieldMatches) {
const parts = match.split(/\s+/);
const type = parts[1];
const name = parts[2];
let isOptional = false;
// Check for optional types (nullable or array)
if (type.endsWith('?')) {
isOptional = true;
type = type.slice(0, -1);
} else if (parts[3] && parts[3].startsWith('[')) {
isOptional = true;
type = 'any[]'; // Represent arrays as any[] for simplicity
}
fieldMatches는 정규표현식의 그룹과 상관없이 매치된 모든 문자열 전체를 배열로 저장한다. 이 배열을 순회한다.
public int? MyProperty;
와 같은 실제 optional field는 추출하지 못한다.[
로 시작할 때, 배열로 간주하고 type을 any[]
로 설정한다.public int[] test;
와 같이 선언하지, public int test []
와 같이 선언하지 않는다.let defaultValue = null;
if (parts.length > 5 && parts[4] === '=') {
defaultValue = parts.slice(5).join(' ');
// Remove quotes for strings if necessary
if (defaultValue.startsWith('"') && defaultValue.endsWith('"')) {
defaultValue = defaultValue.slice(1, -1);
}
}
추출한 문자열에 =가 있는 경우, parts 뒤에 있는 모든 문자열을 디폴트 값으로 취급하고, 이를 변수에 저장한다. 만약 ""로 시작하는 경우는 앞 뒤의 따옴표를 제거한다.
jsFields[name] = {
type: convertCSharpeTypeToJavaScript(type),
optional: isOptional,
default: defaultValue,
};
result 객체의 이름 필드에, 타입, optional 여부, default 값을 저장한 객체를 할당한다.
자잘한 오류들이 있어서 실제로 적용시킬 수는 없으나, 기본적으로 정규표현식을 이용한다는 아이디어를 얻었으므로, 이 아이디어를 기반으로 코드를 개조할 생각이다.
const enums = [
"Season",
"Gender",
"MachineOutputTrigger",
"MachineTimeBlockers",
"QuantityModifier.ModificationType",
"QuantityModifier.QuantityModifierMode",
"PetAnimationLoopMode",
"LimitedStockMode",
"ShopOwnerType",
"StackSizeVisibility",
"QuestDuration",
"WildTreeGrowthStage",
"PlantableRuleContext",
"PlantableResult"
];
function convertCSharpTypeToSchemeData(raw)
{
if(raw === "int") return "Int32";
if(raw === "string") return "String";
if(raw === "double") return "Double";
if(raw === "bool") return "Boolean";
if(raw === "float") return "Single";
if(raw === "char") return "Char";
if(enums.includes(raw)) return "Int32";
let isArray = raw.endsWith("[]");
if(isArray) return `Array<${convertCSharpTypeToSchemeData(raw.slice(0,-2))}>`;
if(raw.startsWith("List")) {
const [,value]= /List<([\w?\[\]<,.>]+)>/.exec(raw);
return [ convertCSharpTypeToSchemeData(value) ];
}
if(raw.startsWith("Dictionary")) {
const [,key,value] = /Dictionary<([\w?\[\]<.>]+),([\w?\[\]<.>]+)>/.exec(raw);
return {
[convertCSharpTypeToSchemeData(key)] : convertCSharpTypeToSchemeData(value)
}
}
return raw;
}
function isPrimitive(type)
{
const primitiveList = [
"Int32",
"Single",
"Double",
"Boolean",
"Char",
"Point",
"Vector2",
"Vector3",
"Vector4",
"Rectangle"
];
return primitiveList.includes(type);
}
function convertCSharpClassToSchemeData(csharpClassString)
{
const cleanClassString = csharpClassString.replace(/\/\/.*|\/\*.*?\*\/|^\s+|\s+$/gm, '').replace(/,\s*/gm, ',');
const regExp = /(\[ContentSerializer\(Optional = true\)\]\n|\[ContentSerializerIgnore\]\n)?public ([\w?\[\]<.,>]+) ([\w_]+)(;| = | { get)/;
const fieldMatches = cleanClassString.match(new RegExp(regExp, "g"));
console.log(fieldMatches);
let result = {};
for(let rawCode of fieldMatches)
{
let [, decorator, type, field] = regExp.exec(rawCode);
let optional = false;
if(decorator === "[ContentSerializerIgnore]\n") continue;
if(type.endsWith("?")) {
optional = true;
type = type.slice(0, -1);
}
type = convertCSharpTypeToSchemeData(type);
if(decorator === "[ContentSerializer(Optional = true)]\n" && !isPrimitive(type)) optional = true;
if(optional) field = "$"+field;
result[field] = type;
}
console.log("export default "+JSON.stringify(result, null, 2)+";");
return result;
}
다음과 같은 코드를 작성하였다. 전반적으로, 제미니가 사용한 코드를 기반으로 코드를 깔끔하게 다듬고 실제 C# 코드에 맞게 수정하였다.
const regExp = /(\[ContentSerializer\(Optional = true\)\]\n|\[ContentSerializerIgnore\]\n)?public ([\w?\[\]<.,>]+) ([\w_]+)(;| = | { get)/;
타입을 정의하는 코드를 추출하는 정규표현식을 정의한다. 이 정규표현식이 cleanClassString에서 타입 코드의 목록을 추출하는 역할도 하지만, 각 타입 코드 문자열에서 데코레이터, 타입, 필드를 추출하는 역할을 하기도 한다.
구체적으로 분석하자면 다음과 같다.
(\[ContentSerializer\(Optional = true\)\]\n|\[ContentSerializerIgnore\]\n)?
: 데코레이터 부분을 추출한다. [ContentSerializer(Optional = true)]
는 해당 c# 필드가 시리얼라이즈되었을 때, 즉 xnb 파일로 변환했을 때 optional한지를 나타내는 필드다. 해당 데코레이터가 존재하면, 해당 필드를 xnb 파일로 다음과 같이 저장한다.[ContentSerializerIgnore]
는 해당 c# 필드가 시리얼라이즈되지 않는다는 의미다. 해당 데코레이터가 존재하면, 저장을 무시한다.public ([\w?\[\]<.,>]+)
: 타입 부분을 추출한다. 타입은 int, string, bool 등만 존재하는 것이 아니라, 사용자 정의 클래스도 존재하며, 제네릭 클래스나 심지어는 .이 붙은 클래스도 존재하기에 다음과 같이 정규표현식을 작성했다.([\w_]+)
: 변수명 부분을 추출한다. 사실 생각해보니 (\w+)
로 작성해도 _
를 인지할 수 있다.(;| = | { get)
: 이 부분이 실제로 변수를 선언하는 것인지를 파악한다. ;
로 끝나거나, =
로 끝나거나, { get
으로 끝나는 경우는 시리얼라이즈되는 변수로 파악한다. 변수가 아닌 함수 등을 거르기 위해 사용되었다.참고로 필드의 디폴트 값은 관심사가 아니기 때문에 추출하지 않는다.
const fieldMatches = cleanClassString.match(new RegExp(regExp, "g"));
위의 정규표현식을 기반으로, C# 코드에서 프로퍼티를 선언한 코드의 목록을 추출한다.
for(let rawCode of fieldMatches)
{
let [, decorator, type, field] = regExp.exec(rawCode);
let optional = false;
if(decorator === "[ContentSerializerIgnore]\n") continue;
if(type.endsWith("?")) {
optional = true;
type = type.slice(0, -1);
}
type = convertCSharpTypeToSchemeData(type);
if(decorator === "[ContentSerializer(Optional = true)]\n" && !isPrimitive(type)) optional = true;
if(optional) field = "$"+field;
result[field] = type;
}
모든 프로퍼티 선언 코드 목록에 대해, 다음의 코드를 실행한다.
let [, decorator, type, field]
와 같이 생긴 요상한 문법은 ES6에서 추가된 구조 분해 할당이다. 콤마 앞에 아무것도 안 쓴 이유는 일치하는 전체 문자열을 안 쓰겠다는 의미.[ContentSerializerIgnore]
인 경우 무시하고 다음으로 넘어간다.[ContentSerializer(Optional = true)]
이고, 타입이 원시 자료형이 아닌 경우, optional을 true로 한다.function convertCSharpTypeToSchemeData(raw)
{
if(raw === "int") return "Int32";
if(raw === "string") return "String";
if(raw === "double") return "Double";
if(raw === "bool") return "Boolean";
if(raw === "float") return "Single";
if(raw === "char") return "Char";
if(enums.includes(raw)) return "Int32";
let isArray = raw.endsWith("[]");
if(isArray) return `Array<${convertCSharpTypeToSchemeData(raw.slice(0,-2))}>`;
if(raw.startsWith("List")) {
const [,value]= /List<([\w?\[\]<,.>]+)>/.exec(raw);
return [ convertCSharpTypeToSchemeData(value) ];
}
if(raw.startsWith("Dictionary")) {
const [,key,value] = /Dictionary<([\w?\[\]<.>]+),([\w?\[\]<.>]+)>/.exec(raw);
return {
[convertCSharpTypeToSchemeData(key)] : convertCSharpTypeToSchemeData(value)
}
}
return raw;
}
C# 타입을 xnb.js의 custom scheme이 사용하는 타입으로 변화시키는 함수다. 다음의 과정을 거친다.
[]
로 끝날 경우, Array 자료형이다. 원시 타입 문자열에서 []
를 제거한 문자열에 대해 다시 해당 변환 과정을 수행하고, Array<타입>
을 리턴한다.List
로 시작할 경우, List 자료형이다. 원시 타입 문자열에서 <>
내부의 값을 추출한 뒤, 해당 값에 대해 변환 과정을 수행하고, [ 타입 ]
을 리턴한다.Dictionary
로 시작할 경우, Dictionary 자료형이다. <>
내부 key와 value 타입 값을 추출한 뒤, 해당 값에 대해 변환 과정을 수행하고, {key 타입 : value 타입}
을 리턴한다.console.log("export default "+JSON.stringify(result, null, 2)+";");
결과값을 콘솔에 추가한다. 사용자는 콘솔에 찍힌 결과값을 복붙해 파일에 저장할 수 있다.
이렇게 C# 코드를 xnb.js가 해석하는 custom scheme으로 변경하는 코드를 만들었더니, 작업 프로세스가 비약적으로 단축된 모습을 보였다. 기존에는 C# 코드를 직접 불러와 일일이 해석하고 타이핑하는 과정을 거쳤다면, 이제는 C# 코드 복사 -> 함수 실행 -> 실행 결과를 js 파일로 저장하는 과정으로 단축되었기 때문이다.
물론 C# 코드에 적힌 필드의 저장 순서와 실제 xnb 파일에 저장된 필드 저장 순서가 다른 경우도 있어서 그 부분은 직접 수정이 필요했지만, 어쨌거나 대부분의 경우는 잘작동되는 모습을 보였다!