F# Build Tools for Unity(ゲームのやつ) - UniFSharpのご紹介
これは F# Advent Calendar 2014の延長戦、 30 日目の記事です。
書いたきっかけ
@zecl ML Advent カレンダーに書いてくださいよーw
F#の人達は知ってるけど、MLな人は知らないとかあるかもしれないですし。
— h_sakurai (@h_sakurai) 2014, 12月 10
結局、25日に間に合いませんで。ゆるふわ #FsAdvent に急遽参加しました。そんなわけで、ML Advent Calendar 2014も合わせてどうぞ。 この記事は非常に誰得でニッチな内容を扱います。ほとんどの場合役には立たないでしょう。F# らしい成分もあまりありませんので、まあ適当に流してください。
UniFSharpとは?
UniFSharpは、私が無職だった(ニートしていた)ときに作成した Unityエディタ拡張 Assetです。割と簡単に導入することができます。
Unityでのゲーム開発はMacで開発する方がなんとなく多い印象がありますが、わたしがVisual Studio使いたい勢ということもあり、こちらWindows専用となっています。Mac対応はそのうちするかも(?) というか、リポジトリ公開してますから、誰か適当にうまいことやっちゃってください。
上の動画の内容は少し古いですが、概要は伝わるかと。UniFSharpを使うと、Unityエディタ上のAssets/Create/F# Script/NewBehaviourScript
のメニューから選択するだけで、F# Scriptを作成できます。 そして、Unityエディタ上でF# ScriptをDLLにビルドすることができます(MSBuild利用)。 Visual Studio(IDE)とも連携するよう実装しており、これにより F#(関数型プログラミング言語)でUnityのゲーム開発がしやすくなります。いわゆる、"作ろうと思えば作れるの知ってるけど、面倒くさくて誰もやらなかったことをやってみた系のツール"です。まぁ、実際やるといろいろ大変。また、オープンソースヒロインのユニティちゃん(ユニティ・テクノロジーズ・ジャパン提供)をマスコットキャラクターに採用しました。ユニティちゃんの音声で時報やイベント、ビルド結果の通知を受けられるという、本来の目的とはまったく関係のない機能も提供しています。
UniFSharpはUnityのEditor拡張です。 ← わかる
Unity Editor から F# Scriptを作成できます。 ← わかる
ユニティちゃんの音声でビルド結果の通知する機能を提供しています。 ← ????
https://t.co/opuuLaNPdT
— みずぴー (@mzp) 2014, 8月 15
Unityを使うならまぁ当然 C# 一択です。がまぁ、趣味で使う分にはフリーダム。
1000 人 Unity ユーザがいるとすると、その中の 4 人は Boo ユーザらしい (戦慄)
— たけしけー (@takeshik) 2014, 9月 4
1000 人 Unity ユーザがいるとすると、その中の 0.01 人は F# ユーザーかもね(適当)
ちなみに、UniFSharp自体も F# で書かれています(ちょっとC# Scriptが混ざってます)。そう、基本的には F#で Unity のほとんどの部分(エディタだろうがゲームだろうが)を書くことができます。この記事では、UniFSharpが提供する機能および、それがどのように実装されているのかについて書きます。ここで紹介でもしないと、GitHubのリポジトリを誰も覗いてくれることもないでしょうし。ハイ。
ご利用の際は、まぁいろいろあると思います(お察し)。覚悟しましょう。
ExecutionEngineException: Attempting to JIT compile method 'Microsoft.FSharp.Core.FSharpFunc`\
誰だよF# がUnityで使えますとか言ったの
— 広告収入欲しさのユーチューバー (@tikal) 2014, 7月 12
この記事を読むその前に...むろほしりょうたさんの初心者がF#をUnityで使ってみた!という記事をオススメします。
F# Scriptの作成
Unityのエディタ拡張では、カスタムメニューを簡単に作ることができます。
F#で実装する場合、Module
に定義した関数にMenuItem
属性を付けるとよいでしょう。
[<MenuItem("Assets/Create/F# Script/NewBehaviourScript",false, 70)>] let createNewBehaviourScript () = FSharpScriptCreateAsset.CreateFSharpScript "NewBehaviourScript.fs" [<MenuItem("Assets/Create/F# Script/NewModule", false, 71)>] let createNewModule () = FSharpScriptCreateAsset.CreateFSharpScript "NewModule.fs" [<MenuItem("Assets/Create/F# Script/", false, 80)>] let createSeparator () = () [<MenuItem("Assets/Create/F# Script/NewTabWindow", false, 91)>] let createNewTabEditorWindow () = FSharpScriptCreateAsset.CreateFSharpScript "NewTabWindow.fs" [<MenuItem("Assets/Create/F# Script/", false, 100)>] let createSeparator2 () = () [<MenuItem("Assets/Create/F# Script/more...", false, 101)>] let more () = MoreFSharpScriptWindow.ShowWindow()
UnityのEditorWindow
は、ScriptableObject
がインスタンス化されたものです。ShowUtility
メソッドを実行すると、必ず手前に表示し続け、タブとして扱えないウィンドウを作れます。C# で作る場合と基本的に同じです。難しくないですね。以下のウィンドウでは、選択されたテンプレートファイルを元に、F# Scriptを生成するという機能を提供しています。
namespace UniFSharp open System.IO open UnityEditor open UnityEngine type MoreFSharpScriptWindow () = inherit EditorWindow () [<DefaultValue>]val mutable index : int static member ShowWindow() = let window = ScriptableObject.CreateInstance<MoreFSharpScriptWindow>() window.title <- FSharpBuildTools.ToolName + " - F# Script" window.ShowUtility() member this.OnGUI() = let scripts = this.GetFSharpScript() this.index <- EditorGUILayout.Popup(this.index, scripts) if GUILayout.Button("Create") then let fileName = scripts.[this.index] FSharpScriptCreateAsset.CreateFSharpScript fileName member this.GetFSharpScript () : string array = Directory.GetFiles(FSharpBuildTools.fsharpScriptTemplatePath, FSharpBuildTools.txtExtensionWildcard) |> Array.map (fun x -> Path.GetFileName(x).Replace(Path.GetExtension(x),""))
F# Scriptのテンプレートの例
namespace #RootNamespace# open UnityEngine type #ClassName# () = inherit MonoBehaviour() [<DefaultValue>] val mutable text : string member public this.Start () = "start..." |> Debug.Log member public this.Update () = "update..." + this.text |> Debug.Log
テンプレートファイルを元に F# Script ファイルを生成したら、その生成したファイルを Unity エディタに Asset として認識させる必要があります。認識をさせないと、Unity の Projectウィンドウ上に表示されません。Assetとして登録する場合、F# Scriptファイルの名前の編集が確定したタイミングで行うようにします。EndNameEditAction
クラスを継承し、Action
メソッドをオーバーライドして実装します。AssetDatabase.LoadAssetAtPath
で、F# ScriptをUnityEngine.Object
として読み込み、ProjectWindowUtil.ShowCreatedAsset
で、Projetウィンドウ上に表示させることができます。
type FSharpScriptCreateAsset () = inherit EndNameEditAction () static member CreateScript defaultName templatePath = let directoryName = let assetPath = AssetDatabase.GetAssetPath(Selection.activeObject) if String.IsNullOrEmpty (assetPath |> Path.GetExtension) then assetPath else assetPath |> getDirectoryName if fsharpScriptCeatable directoryName |> not then EditorUtility.DisplayDialog("Warning", "Folder name that contains the F# Script file,\n must be unique in the entire F# Project.", "OK") |> ignore else let icon = Resources.LoadAssetAtPath(FSharpBuildTools.fsharpIconPath, typeof<Texture2D>) :?> Texture2D ProjectWindowUtil.StartNameEditingIfProjectWindowExists(0, ScriptableObject.CreateInstance<FSharpScriptCreateAsset>(), defaultName, icon, templatePath) static member CreateFSharpScript fileName = let tempFilePath = FSharpBuildTools.fsharpScriptTemplatePath + fileName + FSharpBuildTools.txtExtension FSharpScriptCreateAsset.CreateScript fileName (tempFilePath) override this.Action(instanceId:int, pathName:string, resourceFile:string) = use sr = new StreamReader(resourceFile, new UTF8Encoding(false)) use sw = File.CreateText(pathName) let filename = Path.GetFileNameWithoutExtension(pathName).Replace(" ","") let guid () = System.Guid.NewGuid() |> string let text = Regex.Replace(sr.ReadToEnd(), "#ClassName#", filename) |> fun text -> Regex.Replace(text, "#ModuleName#", filename) |> fun text -> Regex.Replace(text, "#RootNamespace#", FSharpProject.templateRootNamespace pathName) |> fun text -> Regex.Replace(text, "#AssemblyName#", FSharpProject.templateAssemblyName pathName) |> fun text -> Regex.Replace(text, "#Guid#", guid()) sw.Write(text) AssetDatabase.ImportAsset(pathName) let uo = AssetDatabase.LoadAssetAtPath(pathName, typeof<UnityEngine.Object>) ProjectWindowUtil.ShowCreatedAsset(uo)
ちなみに、Visual F# Power Tools(VFPT)では、フォルダ名はプロジェクト全体で一意である必要があるので、UnityのProjectウィンドウ上で階層をフリーダムに作られると厄介なので、そのあたりの階層構造も一応 チェックしていたりという感じです。変な階層を作られると、.fsproj
ファイルがぶっ壊れて開けなくなっちゃいますからね。
@zecl 詳しくは知らんですが、実際は階層構造でなく単に属性的に所属するフォルダとしてフォルダ名をヒモづけられてるのではと予想(´・ω・`)
— omanuke (@omanuke) 2014, 6月 25
@zecl @omanuke @haxe http://t.co/j9KW2eN9qt
Folder機能を作った本人も何故駄目なのかわからないって言ってますね。
実際 <Compile Include="1\2\1\1.fs" /> とかするとエラーになるんですがどこが悪いのか…
— yukitos (@yukitos) 2014, 6月 26
Inspectorで F# コードのプレビューを表示
Inspectorウィンドウで F#コードのプレビューを表示するためには、カスタムエディタを作成します。ただし、カスタムエディタはDLLのみでは実装を完結することができないため(謎の制約)、C# Scriptで。
http://forum.unity3d.com/threads/editor-script-dll-and-regular-script-dll-not-adding-custominspector-scripts.107720/
using System.IO; using UnityEditor; using UnityEngine; using UniFSharp; using Microsoft.FSharp.Core; [CustomEditor(typeof(UnityEngine.Object), true)] public class FSharpScriptInspector : Editor { private string code; void OnEnable() { Repaint(); } public override void OnInteractivePreviewGUI(Rect r, GUIStyle background) { base.OnInteractivePreviewGUI(r, background); } public override void OnInspectorGUI() { GUI.enabled = true; if (!AssetDatabase.GetAssetPath(Selection.activeObject).EndsWith(".fs")) { DrawDefaultInspector(); } else { EditorGUILayout.BeginHorizontal("box"); GUIStyle boldtext = new GUIStyle(); boldtext.fontStyle = FontStyle.Bold; EditorGUILayout.LabelField("Imported F# Script", boldtext); EditorGUILayout.EndHorizontal(); var targetAssetPath = AssetDatabase.GetAssetPath(target); if (!Directory.Exists(targetAssetPath) && File.Exists(targetAssetPath)) { var sr = File.OpenText(targetAssetPath); code = sr.ReadToEnd(); sr.Close(); GUIStyle myStyle = new GUIStyle(); GUIStyle style = EditorStyles.textField; myStyle.border = style.border; myStyle.contentOffset = style.contentOffset; myStyle.normal.background = style.normal.background; myStyle.padding = style.padding; myStyle.wordWrap = true; EditorGUILayout.LabelField(code, myStyle); } var rec = EditorGUILayout.BeginHorizontal(); if (GUI.Button(new Rect(rec.width - 80, 25, 50, 15), "vs-sln", EditorStyles.miniButton)) { var path = AssetDatabase.GetAssetPath(Selection.activeObject); var basePath = FSharpProject.GetProjectRootPath(); var fileName = PathUtilModule.GetAbsolutePath(basePath, path); UniFSharp.FSharpSolution.OpenExternalVisualStudio(SolutionType.FSharp, fileName); } if (GUI.Button(new Rect(rec.width - 145, 25, 60, 15), "mono-sln", EditorStyles.miniButton)) { UniFSharp.FSharpSolution.OpenExternalMonoDevelop(); } EditorGUILayout.EndHorizontal(); } } }
Editor
を継承しOnInspectorGUI
をオーバーライドし、Projectウィンドウで選択されたF# Scriptを読み込んで表示するよう実装します。雑ですが以上。
F# DLL のビルド
Unity上から MSBuildでビルドするだけの簡単なお仕事です。これといって特筆すべきことはありません。誰かソースきれいにして。
namespace UniFSharp open System open System.IO open System.Diagnostics open System.Text open System.Xml open UnityEditor [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] module MSBuild = let private initOutputDir outputDirPath = if (not <| Directory.Exists(outputDirPath)) then Directory.CreateDirectory(outputDirPath) |> ignore else Directory.GetFiles(outputDirPath) |> Seq.iter (fun file -> File.Delete(file)) AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate) let private getAssemblyName (projectFilePath:string) = let xdoc = new XmlDocument() xdoc.Load(projectFilePath) let xnm = new XmlNamespaceManager(xdoc.NameTable) xnm.AddNamespace("ns", "http://schemas.microsoft.com/developer/msbuild/2003") let node = xdoc.SelectSingleNode("/ns:Project/ns:PropertyGroup/ns:AssemblyName", xnm) let node = xdoc.SelectSingleNode("/ns:Project/ns:PropertyGroup/ns:AssemblyName") if (node = null) then "" else node.InnerText let private getAargs (projectFilePath:string) (outputDirPath:string) isDebug = let projectFilePath = projectFilePath |> replaceDirAltSepFromSep let outputDirPath = outputDirPath |> replaceDirAltSepFromSep // http://msdn.microsoft.com/ja-jp/library/bb629394.aspx let args = new StringBuilder() args.AppendFormat("\"{0}\"", projectFilePath) .AppendFormat(" /p:Configuration={0}", if isDebug then "Debug" else "Release") .AppendFormat(" /p:OutputPath=\"{0}\"", outputDirPath) .Append(" /p:OptionExplicit=true") .Append(" /p:OptionCompare=binary") .Append(" /p:OptionStrict=true") .Append(" /p:OptionInfer=true") .Append(" /p:BuildProjectReferences=false") .AppendFormat(" /p:DebugType={0}", if isDebug then "full" else "pdbonly") .AppendFormat(" /p:DebugSymbols={0}", if isDebug then "true" else "false") .AppendFormat(" /p:VisualStudioVersion={0}", "12.0") // TODO //.AppendFormat("{0}", String.Format(" /p:DocumentationFile={0}/{1}.xml", outputDirPath, getAssemblyName projectFilePath)) .AppendFormat(" /l:FileLogger,Microsoft.Build.Engine;logfile={0}", String.Format("{0}/{1}.log", outputDirPath, if isDebug then "DebugBuild" else "ReleaseBuild")) .Append(" /t:Clean;Rebuild") |> string let getMSBuildPath (version:string) = let msBuildPath = (String.Format(@"SOFTWARE\Microsoft\MSBuild\{0}", version), @"MSBuildOverrideTasksPath") ||> UniFSharp.Registory.getReg Path.Combine(msBuildPath, "MSBuild.exe") let execute msBuildVersion projectFilePath outputDirPath isDebug outputDataReceivedEventHandler errorDataReceivedEventHandler = use p = new Process() outputDirPath |> initOutputDir p.StartInfo.WindowStyle <- ProcessWindowStyle.Hidden p.StartInfo.CreateNoWindow <- true p.StartInfo.UseShellExecute <- true p.StartInfo.FileName <- getMSBuildPath msBuildVersion p.StartInfo.Arguments <- getAargs projectFilePath outputDirPath isDebug if (outputDataReceivedEventHandler = null |> not || errorDataReceivedEventHandler = null |> not) then p.StartInfo.UseShellExecute <- false p.StartInfo.CreateNoWindow <- true p.StartInfo.WindowStyle <- ProcessWindowStyle.Hidden if (outputDataReceivedEventHandler = null |> not) then p.StartInfo.RedirectStandardOutput <- true p.OutputDataReceived.AddHandler outputDataReceivedEventHandler if (errorDataReceivedEventHandler = null |> not) then p.StartInfo.RedirectStandardError <- true p.ErrorDataReceived.AddHandler errorDataReceivedEventHandler if p.Start() then if (outputDataReceivedEventHandler = null |> not) then p.BeginOutputReadLine() if (errorDataReceivedEventHandler = null |> not) then p.BeginErrorReadLine() p.WaitForExit() p.ExitCode else p.ExitCode
UniFSharpでは、ビルドの結果をユニティちゃんが通知してくれます。ビルドエラーだとこんな感じ
F# Scriptのドラック&ドロップについて
「UniFSharp を使えば F# Script ファイルをUnity上で作れる」とは言っても、実際にScriptファイルとして動作するようには実装していないくて、実際はDLL化したアセンブリをUnityで読み込んで利用しているため、通常は Projectウィンドウに表示しているだけの F# Scriptファイルを、Inspectorウィンドウにドラック&ドロップしGameObjectにComponentとして追加することはできません。UniFSharpでは、アセンブリの内容を解析して、疑似的に F# Scriptファイルをドラッグ&ドロップしているかのような操作感覚を実現しています。
F# DLLとF# ScriptからMonoBehaviourの派生クラスを探索するモードは2種類用意していて、1つは、F# Scriptファイルを読み取って、シンプルな正規表現でクラス名を抽出し、アセンブリからMonoBehaviourの派生クラスを検索する方法。もう一つは、F# ScriptファイルをF# コンパイラサービスを利用して、解析して厳密にクラス名を抽出する方法。前者は精度は低いが早い。後者は精度は高いが遅い。それぞれ一長一短がある。
カスタムエディタということで、F# コンパイラサービスを利用する部分を除いては、またC#。
using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Text.RegularExpressions; using UnityEditor; using UnityEngine; using UniFSharp; using System; using System.Collections.Generic; using System.Diagnostics; [CustomEditor(typeof(UnityEngine.Transform))] public class TransformInspector : Editor { Vector3 position; void OnEnable() { Repaint(); } public override void OnInspectorGUI() { EditorGUILayout.BeginVertical(); (this.target as Transform).localRotation = Quaternion.Euler(EditorGUILayout.Vector3Field("Local Rotation", (this.target as Transform).localRotation.eulerAngles)); (this.target as Transform).localPosition = EditorGUILayout.Vector3Field("Local Position", (this.target as Transform).localPosition); (this.target as Transform).localScale = EditorGUILayout.Vector3Field("Local Scale", (this.target as Transform).localScale); EditorGUILayout.EndVertical(); // F# Script Drag % Drop if (DragAndDrop.objectReferences.Length > 0 && AssetDatabase.GetAssetPath(DragAndDrop.objectReferences[0]).EndsWith(".fs")) { DragDropArea<UnityEngine.Object>(null, draggedObjects => { var dropTarget = this.target as Transform; foreach (var draggedObject in draggedObjects) { var outputPath = FSharpProject.GetNormalOutputAssemblyPath(); if (!Directory.Exists(outputPath)) { EditorUtility.DisplayDialog("Warning", "F# Assembly is not found.\nPlease Build.", "OK"); break; } var notfound = true; foreach (var dll in Directory.GetFiles(outputPath, "*.dll")) { var fileName = Path.GetFileName(dll); if (fileName == "FSharp.Core.dll") continue; var assem = Assembly.LoadFrom(dll); IEnumerable<Type> behaviors = null; switch (UniFSharp.FSharpBuildToolsWindow.FSharpOption.assemblySearch) { case AssemblySearch.Simple: var @namespace = GetNameSpace(AssetDatabase.GetAssetPath(draggedObject)); var typeName = GetTypeName(AssetDatabase.GetAssetPath(draggedObject)); behaviors = assem.GetTypes().Where(type => typeof(MonoBehaviour).IsAssignableFrom(type) && type.FullName == @namespace + typeName); break; case AssemblySearch.CompilerService: var types = GetTypes(AssetDatabase.GetAssetPath(draggedObject)); behaviors = assem.GetTypes().Where(type => typeof(MonoBehaviour).IsAssignableFrom(type) && types.Contains(type.FullName)); break; default: break; } if (behaviors != null && behaviors.Any()) { DragAndDrop.AcceptDrag(); foreach (var behavior in behaviors) { dropTarget.gameObject.AddComponent(behavior); notfound = false; } } } if (notfound) { EditorUtility.DisplayDialog("Warning", "MonoBehaviour is not found in the F # assembly.", "OK"); return; } } }, null, 50); } } public static void DragDropArea<T>(string label, Action<IEnumerable<T>> onDrop, Action onMouseUp, float height = 50) where T : UnityEngine.Object { GUILayout.Space(15f); Rect dropArea = GUILayoutUtility.GetRect(0.0f, 50.0f, GUILayout.ExpandWidth(true)); if (label != null) GUI.Box(dropArea, label); Event currentEvent = Event.current; if (!dropArea.Contains(currentEvent.mousePosition)) return; if (onMouseUp != null) if (currentEvent.type == EventType.MouseUp) onMouseUp(); if (onDrop != null) { if (currentEvent.type == EventType.DragUpdated || currentEvent.type == EventType.DragPerform) { DragAndDrop.visualMode = DragAndDropVisualMode.Copy; if (currentEvent.type == EventType.DragPerform) { EditorGUIUtility.AddCursorRect(dropArea, MouseCursor.CustomCursor); onDrop(DragAndDrop.objectReferences.OfType<T>()); } Event.current.Use(); } } } private string GetNameSpace(string path) { var @namespace = ""; using (var sr = new StreamReader(path, new UTF8Encoding(false))) { var text = sr.ReadToEnd(); string pattern = @"(?<![/]{2,})[\x01-\x7f]*namespace[\s]*(?<ns>.*?)\n"; var re = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Singleline); foreach (Match m in re.Matches(text)) { @namespace = m.Groups["ns"].Value.Trim() != "" ? m.Groups["ns"].Value.Trim() + "." : ""; break; } } return @namespace; } private string GetTypeName(string path) { var typeName = ""; using (var sr = new StreamReader(path, new UTF8Encoding(false))) { var text = sr.ReadToEnd(); string pattern = @"(?<![/]{2,}\s{0,})type[\s]*(?<type>.*?)(?![\S\(\)\=\n])"; var re = new Regex(pattern); foreach (Match m in re.Matches(text)) { typeName = m.Groups["type"].Value.Trim(); break; } } return typeName; } private string[] GetTypes(string path) { var path2 = UniFSharp.PathUtilModule.GetAbsolutePath(Application.dataPath, path); var p = new Process(); p.StartInfo.FileName = FSharpBuildToolsModule.projectRootPath + @"Assembly\GN_merge.exe"; p.StartInfo.Arguments = path2 + " " + "DEBUG"; p.StartInfo.CreateNoWindow = true; p.StartInfo.UseShellExecute = false; p.StartInfo.RedirectStandardOutput = true; p.Start(); p.WaitForExit(); var outputString = p.StandardOutput.ReadToEnd(); var types = outputString.Split(new string[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); return types; } }
F# コンパイラサービスを使って、F# Scriptファイルから名前空間を含むクラス名の探索はこんな感じ。
module Parser open System open Microsoft.FSharp.Compiler.SourceCodeServices open Microsoft.FSharp.Compiler.Ast let private checker = InteractiveChecker.Create() let private getUntypedTree (file, input, conditionalDefines) = let otherFlags = match conditionalDefines with | [||] -> [||] | _ -> conditionalDefines |> Array.map (fun x -> "--define:" + x ) let checkOptions = checker.GetProjectOptionsFromScript(file, input, otherFlags = otherFlags) |> Async.RunSynchronously let untypedRes = checker.ParseFileInProject(file, input, checkOptions) |> Async.RunSynchronously match untypedRes.ParseTree with | Some tree -> tree | None -> failwith "failed to parse" let rec private getAllFullNameOfType' modulesOrNss = modulesOrNss |> Seq.map(fun moduleOrNs -> let (SynModuleOrNamespace(lid, isModule, moduleDecls, xmlDoc, attribs, synAccess, m)) = moduleOrNs let topNamespaceOrModule = String.Join(".",(lid.Head::lid.Tail)) //inner modules let modules = moduleDecls.Head::moduleDecls.Tail getDeclarations modules |> Seq.map (fun x -> String.Join(".", [topNamespaceOrModule;x])) ) |> Seq.collect id and private getDeclarations moduleDecls = Seq.fold (fun acc declaration -> match declaration with | SynModuleDecl.NestedModule(componentInfo, modules, _isContinuing, _range) -> match componentInfo with | SynComponentInfo.ComponentInfo(_,_,_,lid,_,_,_,_) -> let moduleName = String.Join(".",(lid.Head::lid.Tail)) let children = getDeclarations modules seq { yield! acc yield! children |> Seq.map(fun child -> moduleName + "+" + child) } | SynModuleDecl.Types(typeDefs, _range) -> let types = typeDefs |> Seq.map(fun typeDef -> match typeDef with | SynTypeDefn.TypeDefn(componentInfo,_,_,_) -> match componentInfo with | SynComponentInfo.ComponentInfo(_,typarDecls,_,lid,_,_,_,_) -> let typarString = typarDecls |> function | [] -> "" | x -> "`" + string x.Length let typeName = String.Join(".",(lid.Head::lid.Tail)) typeName + typarString) seq { yield! acc yield! types } | _ -> acc ) Seq.empty moduleDecls let getAllFullNameOfType input conditionalDefines = let tree = getUntypedTree("/dummy.fsx", input, conditionalDefines) match tree with | ParsedInput.ImplFile(ParsedImplFileInput(file, isScript, qualName, pragmas, hashDirectives, modules, b)) -> getAllFullNameOfType' modules | _ -> failwith "(*.fsi) not supported."
module Program open System open System.IO open Microsoft.FSharp.Compiler.Ast open Parser [<EntryPoint>] let main argv = let cmds = System.Environment.GetCommandLineArgs() if cmds.Length < 2 then 0 else let fileName = cmds.[1].Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) let conditionalDefines = if cmds.Length > 2 then cmds.[2].Split(';') else [||] let input = File.ReadAllText(fileName) getAllFullNameOfType input conditionalDefines |> Seq.iter(fun x -> printfn "%s" x) 0
ところで、F# コンパイラサービスの対象フレームワークは .NET Framework4
以上です。いまはまだ Unityでこのアセンブリを読み込むことはできません。残念!!なのですが、ここで、Microsoftが提供しているILMerge
という神ツールを使う(苦肉の策)ことにより、それを回避し実現してる(アッハイ)。
F# Projectファイルの操作とVisual Studioとの連携
Unityエディタで F# Script を作成することをサポートしたということは、つまり、IDEとの連携もサポートするってことだよね。Projectウィンドウ上でF# Scriptファイルを追加したり、ファイルのパスを移動したり、ファイルを削除したタイミングで.fsproj
ファイル(XML) の内容が書き換わってくれないと、それぜーんぜん役に立たない。そういうこと。この実装がけっこー面倒くさかった...。
こんな感じ
Assetを追加・削除・移動した際に独自の処理をしたい場合は、AssetPostprocessor
を継承して適宜処理を実装する。さらに、それがUnityで標準では扱われないファイルの場合(まさに今回の F# Scriptがこの場合)には、OnPostprocessAllAssets
メソッドを実装する。そこで .fsproj
ファイルをごにょごにょすることで、これを実現できる。
コードは、こんな雰囲気(あばばばば)
namespace UniFSharp open System open System.IO open System.Linq open System.Xml.Linq open UnityEditor open UnityEngine type FSharpScriptAssetPostprocessor () = inherit AssetPostprocessor () static let ( !! ) s = XName.op_Implicit s static let getXDocCompileIncureds (fsprojXDoc:XDocument) (ns:string) (projectFileType:ProjectFileType) = let elements = fsprojXDoc.Root.Elements(!!(ns + "ItemGroup")).Elements(!!(ns + "Compile")) elements |> Seq.map (fun x -> x.Attribute(!!"Include").Value |> replaceDirSepFromAltSep) static let getNewCompileIncludeElement(ns:string) (file:string) = XElement(!!(ns + "Compile"), new XAttribute(!!"Include", file)) static let getNewItemGroupCompileIncludeElement (ns:string) (file:string) = XElement(!!(ns + "ItemGroup"), new XElement(!!(ns + "Compile"), new XAttribute(!!"Include", file))) static let getXDocComiles (fsprojXDoc:XDocument) (ns:string) = fsprojXDoc.Root.Elements(!!(ns + "ItemGroup")).Elements(!!(ns + "Compile")) static let getNotExitsFiles (compileIncludes:seq<string>) (projectFileType:ProjectFileType) = let basePath = FSharpProject.getProjectRootPath() let files = FSharpProject.getAllFSharpScriptAssets(projectFileType) |> Seq.map (fun x -> getRelativePath basePath x) Seq.fold(fun acc file -> let file = file |> replaceDirSepFromAltSep if not (compileIncludes |> Seq.exists ((=)file)) then seq { yield! acc yield file } else acc) Seq.empty files static let addCompileIncludeFiles (fsprojXDoc:XDocument) (ns:string) (compileIncludes:seq<string>) (projectFileType:ProjectFileType) = let notExists = getNotExitsFiles compileIncludes projectFileType notExists |> Seq.iter (fun file -> let newElem = getNewCompileIncludeElement ns file let compiles = getXDocComiles fsprojXDoc ns if (compiles.Any()) then let addPoint () = let directoryPoint = compiles |> Seq.toList |> Seq.filter (fun x -> let includeFile = x.Attribute(!!"Include").Value let includeDirectory = getDirectoryName(includeFile) |> replaceDirSepFromAltSep let directory = getDirectoryName(file) |> replaceDirSepFromAltSep includeDirectory = directory) if directoryPoint.Any() then directoryPoint |> Seq.toList else compiles|> Seq.toList addPoint().Last().AddAfterSelf(newElem) else let newItemGroupElem = getNewItemGroupCompileIncludeElement ns file fsprojXDoc.Root.Add(newItemGroupElem)) static let getRemoveFiles (compileIncludes:seq<string>) (projectFileType:ProjectFileType) = let basePath = FSharpProject.getProjectRootPath() Seq.fold(fun acc ``include`` -> let ``include`` = ``include`` |> replaceDirSepFromAltSep let files = FSharpProject.getAllFSharpScriptAssets(projectFileType) |> Seq.map (fun x -> getRelativePath basePath x) |> Seq.map (fun x -> x |> replaceDirSepFromAltSep) if (not <| files.Contains(``include``)) then seq { yield! acc yield ``include`` } else acc) Seq.empty compileIncludes static let removeCompileIncludeFiles (fsprojXDoc:XDocument) (ns:string) (compileIncludes:seq<string>) (projectFileType:ProjectFileType) = let removedFiles = getRemoveFiles compileIncludes projectFileType removedFiles |> Seq.iter (fun file -> let compileItems = (fsprojXDoc.Root.Elements(!!(ns + "ItemGroup")).Elements(!!(ns + "Compile"))) if compileItems |> Seq.length = 1 && (compileItems |> Seq.exists (fun x -> x.Attribute(!!"Include").Value = file)) then let parent = compileItems |> Seq.map(fun x -> x.Parent) |> Seq.head parent.Remove() else (compileItems |> Seq.filter (fun x -> x.Attribute(!!"Include").Value = file)).Remove()) static let createOrUpdateProject (projectFileType:ProjectFileType) = let fsprojFileName = FSharpProject.getFSharpProjectFileName(projectFileType) let fsprojFilePath = FSharpProject.getFSharpProjectPath(fsprojFileName) if (not <| File.Exists(fsprojFilePath)) then FSharpProject.createFSharpProjectFile(projectFileType) |> ignore else let fsprojXDoc = XDocument.Load(fsprojFilePath) let ns = "{" + String.Format("{0}", fsprojXDoc.Root.Attribute(!!"xmlns").Value) + "}" let compileIncludes = getXDocCompileIncureds fsprojXDoc ns projectFileType addCompileIncludeFiles fsprojXDoc ns compileIncludes projectFileType removeCompileIncludeFiles fsprojXDoc ns compileIncludes projectFileType fsprojXDoc.Save(fsprojFilePath) static let deleteProject (projectFileType:ProjectFileType) (assetPath:string) = let assetPath = assetPath |> replaceDirSepFromAltSep let fsprojFileName = FSharpProject.getFSharpProjectFileName projectFileType if (File.Exists(fsprojFileName)) then let basePath = FSharpProject.getProjectRootPath() let fsprojXDoc = XDocument.Load(fsprojFileName) let ns = "{" + String.Format("{0}", fsprojXDoc.Root.Attribute(!!"xmlns").Value) + "}" let compileIncludes = fsprojXDoc.Root .Elements(!!(ns + "ItemGroup")) .Elements(!!(ns + "Compile")) |> Seq.map (fun x -> x.Attribute(!!"Include").Value) let compileIncludes = compileIncludes |> Seq.map (fun x -> x |> replaceDirSepFromAltSep) fsprojXDoc.Root .Elements(!!(ns + "ItemGroup")) .Elements(!!(ns + "Compile")) .Where(fun x -> x.Attribute(!!"Include").Value |> replaceDirSepFromAltSep = assetPath).Remove() fsprojXDoc.Save(fsprojFileName) else () static let createOrUpdateEditor () = ProjectFileType.VisualStudioEditor |> createOrUpdateProject ProjectFileType.MonoDevelopEditor |> createOrUpdateProject static let createOrUpdateNormal () = ProjectFileType.VisualStudioNormal |> createOrUpdateProject ProjectFileType.MonoDevelopNormal |> createOrUpdateProject static let createOrUpdate () = createOrUpdateNormal() createOrUpdateEditor() static let filterFSharpScript x = x |> Seq.filter(fun assetPath -> Path.GetExtension(assetPath) = FSharpBuildTools.fsExtension) static let onImportedAssets(importedAssets) = importedAssets |> filterFSharpScript |> fun _ -> createOrUpdate () UniFSharp.FSharpSolution.CreateSolutionFile() static let onDeletedAssets(deletedAssets) = deletedAssets |> filterFSharpScript |> Seq.iter (fun assetPath -> if (FSharpProject.containsEditorFolder assetPath) then deleteProject ProjectFileType.VisualStudioEditor assetPath deleteProject ProjectFileType.MonoDevelopEditor assetPath else deleteProject ProjectFileType.VisualStudioNormal assetPath deleteProject ProjectFileType.MonoDevelopNormal assetPath) static let onMovedAssets(movedAssets) = movedAssets |> filterFSharpScript |> Seq.iter (fun assetPath -> let assetAbsolutePath = assetPath |> (getAbsolutePath Application.dataPath) let fileName = assetAbsolutePath |> Path.GetFileName if fsharpScriptCeatable assetAbsolutePath |> not then EditorUtility.DisplayDialog("Warning", "Folder name that contains the F# Script file,\n must be unique in the entire F# Project.\nMove to Assets Folder.", "OK") |> ignore AssetDatabase.MoveAsset(assetPath, "Assets/" + fileName) |> ignore AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate)) static let onMovedFromPathAssets(movedFromPath) = if movedFromPath |> filterFSharpScript |> Seq.exists (fun _ -> true) then createOrUpdateNormal() static member OnPostprocessAllAssets (importedAssets:string array, deletedAssets:string array, movedAssets:string array, movedFromPath:string array) = onImportedAssets importedAssets onDeletedAssets deletedAssets onMovedAssets movedAssets onMovedFromPathAssets movedFromPath
また、UnityのProjectウィンドウ上でF# Scriptをダブルクリックした際に、Visual Studio上でそのファイルをアクティブにする動作を実現するために、EnvDTEを利用した。
http://msdn.microsoft.com/ja-jp/library/envdte.dte.aspx
UnityからEnvDTE叩こうとするとUnityが死ぬ^p^
— ぜくる (@zecl) 2014, 6月 25
これはきな臭い...。UniFSharpがWindows専用であることが滲み出ているコードですね。はい(真顔)
namespace DTE open System open System.Linq open System.Runtime.InteropServices open System.Runtime.InteropServices.ComTypes open EnvDTE module AutomateVisualStudio = let is64BitProcess = (IntPtr.Size = 8) [<DllImport("kernel32.dll", SetLastError = true, CallingConvention = CallingConvention.Winapi)>] extern [<MarshalAs(UnmanagedType.Bool)>] bool IsWow64Process([<In>] IntPtr hProcess, [<Out>] bool& wow64Process) [<CompiledName "InternalCheckIsWow64">] let internalCheckIsWow64 () = let internalCheckIsWow64 () = if ((Environment.OSVersion.Version.Major = 5 && Environment.OSVersion.Version.Minor >= 1) || Environment.OSVersion.Version.Major >= 6) then use p = System.Diagnostics.Process.GetCurrentProcess() let mutable retVal = false if (not <| IsWow64Process(p.Handle, &retVal)) then false else retVal else false is64BitProcess || internalCheckIsWow64() [<CompiledName "Is64BitOperatingSystem">] let is64BitOperatingSystem = is64BitProcess || internalCheckIsWow64 () [<CompiledName "GetVisualStudioInstallationPath">] let getVisualStudioInstallationPath (version:string) = let installationPath = if (is64BitOperatingSystem) then Registory.getReg (String.Format(@"SOFTWARE\Wow6432Node\Microsoft\VisualStudio\{0}", version)) "InstallDir" else Registory.getReg (String.Format(@"SOFTWARE\Microsoft\VisualStudio\{0}", version)) "InstallDir" installationPath + "devenv.exe" let openExternalScriptEditor vsVersion solutionPath = let p = new System.Diagnostics.Process() p.StartInfo.Arguments <- solutionPath p.StartInfo.FileName <- getVisualStudioInstallationPath vsVersion p.Start() [<DllImport("ole32.dll")>] extern int CreateBindCtx(uint32 reserved, [<Out>] IBindCtx& ppbc) let marshalReleaseComObject(objCom: obj) = let i = ref 1 if (objCom <> null && Marshal.IsComObject(objCom)) then while (!i > 0) do i := Marshal.ReleaseComObject(objCom) let getDTE' (processId:int) (dteVersion:string) = let progId = String.Format("!VisualStudio.DTE.{0}:", dteVersion) + processId.ToString() let mutable bindCtx : IBindCtx = null; let mutable rot : IRunningObjectTable= null; let mutable enumMonikers :IEnumMoniker = null; let mutable runningObject : obj = null try Marshal.ThrowExceptionForHR(CreateBindCtx(0u, &bindCtx)) bindCtx.GetRunningObjectTable(&rot) rot.EnumRunning(&enumMonikers) let moniker = Array.create<IMoniker>(1) null let numberFetched = IntPtr.Zero let cont' = ref true while (enumMonikers.Next(1, moniker, numberFetched) = 0 && !cont') do let runningObjectMoniker = moniker.[0] let mutable name = null try if (runningObjectMoniker <> null) then runningObjectMoniker.GetDisplayName(bindCtx, null, &name) with | :? UnauthorizedAccessException -> () // do nothing if (not <| String.IsNullOrEmpty(name) && String.Equals(name, progId, StringComparison.Ordinal)) then Marshal.ThrowExceptionForHR(rot.GetObject(runningObjectMoniker, &runningObject)) cont' := false finally if (enumMonikers <> null) then enumMonikers |> marshalReleaseComObject if (rot <> null) then rot |> marshalReleaseComObject if (bindCtx <> null) then bindCtx |> marshalReleaseComObject runningObject :?> EnvDTE.DTE let tryGetDTE (dteVersion:string) (targetSolutionFullName:string) tryMax = let getVisualStudioProcesses () = System.Diagnostics.Process.GetProcesses() |> Seq.where(fun x -> try x.ProcessName = "devenv" with | _ ->false) try let retry = RetryBuilder(tryMax,1.) retry { return getVisualStudioProcesses() |> Seq.tryPick(fun p -> let dte = getDTE' p.Id dteVersion if (targetSolutionFullName.ToLower() = dte.Solution.FullName.ToLower()) then Some (dte,p) else None)} with | _ -> None let showDocument (dte:EnvDTE.DTE) (documentFullName:string) = let targetItem = retry{ let targetItem = dte.Solution.FindProjectItem(documentFullName) if (targetItem = null) then return None else return Some targetItem } match targetItem with | None -> () | Some target -> if (not <| target.IsOpen(Constants.vsViewKindCode)) then target.Open(Constants.vsViewKindCode) |> ignore target.Document.Activate() else target.Document.Activate() let jumpToLine dte documentFullName lineNumber = showDocument dte documentFullName let selectionDocument = dte.ActiveDocument.Selection :?> EnvDTE.TextSelection try selectionDocument.GotoLine(lineNumber, true) with | _ -> ()
namespace DTE open System open System.Runtime.InteropServices open System.IO open Microsoft.Win32 [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] module Registory = let getReg keyPath valueName = try use rKey = Registry.LocalMachine.OpenSubKey(keyPath) let location = rKey.GetValue(valueName) |> string rKey.Close() location with e -> new Exception(String.Format("registry key:[{0}] value:[{1}] is not found.", keyPath, valueName)) |> raise
namespace DTE open System open System.IO open AutomateVisualStudio module Program = [<EntryPoint>] let main argv = let cmds = System.Environment.GetCommandLineArgs() if cmds.Length < 4 then 0 else let vsVersion = cmds.[1] // "12.0" let solutionPath = cmds.[2].Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) let targetDocumetFileName = cmds.[3].Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) if String.IsNullOrEmpty(vsVersion) then 0 else if File.Exists(solutionPath) |> not then 0 else if File.Exists(targetDocumetFileName) |> not then 0 else let active dte = if (cmds.Length = 4) then showDocument dte targetDocumetFileName else let lineNumber = cmds.[4] if (lineNumber <> null) then let num = Int32.Parse(lineNumber) jumpToLine dte targetDocumetFileName num let dte = tryGetDTE vsVersion solutionPath 2 match dte with | None -> if openExternalScriptEditor vsVersion solutionPath then let dte = tryGetDTE vsVersion solutionPath 30 dte |> Option.iter (fun (dte,p) -> active dte; Microsoft.VisualBasic.Interaction.AppActivate(p.Id)) | Some (dte,p) -> active dte Microsoft.VisualBasic.Interaction.AppActivate(p.Id) 0
あと、Retryビルダー。アッハイ。モナドじゃねえっス。
namespace DTE open System.Threading [<AutoOpen>] module Retry = type RetryBuilder(count, seconds) = member x.Return(a) = a member x.Delay(f) = f member x.Zero() = failwith "Zero" member x.Run(f) = let rec loop(n) = if n = 0 then failwith "retry failed" else try f() with e -> Thread.Sleep(seconds * 1000. |> int) loop(n-1) loop count let retry = RetryBuilder(30,1.)
UniFSharpのオプション画面
ユニティちゃんの背景が印象的な画面です。
このオプション画面で、作成するF# プロジェクトの構成の詳細を設定できます。細かい説明は省きます(雑。
ユニティちゃんの機能もろもろ
ユニティ・テクノロジーズ・ジャパンが無償で提供してくれているユニティちゃんのAsset に同封されている多彩な音声。せっかくあるので使ってみたい。特に「進捗どうですか?」とか使わない手はない。そういや、いわるゆる萌え系だとか痛い系のIDEって結構あるけど、しゃべる感じのやつってあんまりないよなぁ。とかいうのが一応実装動機ということで。
- ・起動時ボイス(ON/OFF)
- ・ビルド時ボイス(ON/OFF)
- ・進捗どうですか?(ON/OFF, 通知間隔指定あり)
- ・時報通知のボイス(ON/OFF)
- ・イベント通知のボイス(ON/OFF)
- ・誕生日のお祝い(ON/OFF, 日付を指定)
F#でUnityゲーム開発する気はなくても、Unityをお使いの方で、ユニティちゃんに「進捗どうですか?」とか言われたい人は、まぁ使ってみてくださいという感じで(適当)。
open UnityEngine open UnityEditor [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] module AudioUtil = [<CompiledName "PlayClip">] let playClip (clip:AudioClip) = let method' = let unityEditorAssembly = typeof<AudioImporter>.Assembly let audioUtilClass = unityEditorAssembly.GetType("UnityEditor.AudioUtil") audioUtilClass.GetMethod( "PlayClip", BindingFlags.Static ||| BindingFlags.Public, null, [|typeof<AudioClip>|], null) method'.Invoke(null, [|clip|]) |> ignore
Unityエディタ上で、音声ファイルを再生したい系の人は上記のような感じのをひとつこさえておけば、ハカドルかもね。
MonoDevelop、Xamarin、Visual Studioで Unity の F# DLLデバッグ
UniFSharpとは直接は関係ありませんが、Unity で F# DLLをデバッグする方法も紹介しておきたい。
基本的には、Unity ユーザーマニュアルに書いてあるとおりにすればよいです。
Unity プロジェクトでの Mono DLL 使用 / Using Mono DLLs in a Unity Project
ということで、.fsproj
のビルド後イベントに、下記のような感じで設定しておくと捗るかもしれません(パスとかは適当に変えて)。
if exist "..\..\Assets\Assembly-FSharp-Editor\$(TargetName).pdb" call "C:\Program Files (x86)\Unity\Editor\Data\Mono\lib\mono\2.0\pdb2mdb.exe" "..\..\Assets\Assembly-FSharp-Editor\$(TargetName).dll"
Visual Studio 2013 Tools for Unityが無償提供され、あらゆるアプリを開発できる最強の開発ツールとの触れ込みのVisual Studio 2013 Community Editionが無償提供されたことで、誰でもVisual Studio で Unityのデバッグ実行ができるようになりました。本当にいい世の中になったものです。F# をお使いなら、 Visual F# Power Toolsも利用できますし、めしうま状態必至。
あ、fsharp .org に VS2013 Tools for Unityを使えば、F# で書いたDLLをデバッグ実行できるYOとしれっと書いているが、誰得? http://t.co/YHUiTdqSCI
— ぜくる (@zecl) 2014, 12月 11
おまけ
Unityえふしゃーぷまん達
http://t.co/Fdux8rbZCb
FSharpをUnityでも使えるんだろうけど、dll作って、アセットフォルダに突っ込んで読み込みかぁ。さらさらっと出来ると嬉しいなぁ。しかし、やる気がないw
— h_sakurai (@h_sakurai) 2014, 12月 10
UnityをF# で使おう委員会
— 切れ痔 (@lmdexpr) 2014, 9月 1
ヤッター! UnityでFSharp動いたあぁー!!!
— c000 :: RealWorld → (@c255) 2014, 5月 27
unitychan2Dを F# へ移植してみた https://t.co/qV0YI1NkVh #fsharp #unitychan pic.twitter.com/5G2DT4V6Ci
— ぜくる (@zecl) 2014, 7月 12
Unityもっと安くならないかな……。あとついでにスクリプトがF#に対応してくれないかな……
— 徹人 (@t_tetsuzin) 2014, 6月 20
当然、Unity でオール http://t.co/kfcpCbcFQ2 で書くことも可能(誰得すぎて誰もやらない
— ぜくる (@zecl) 2014, 2月 18
「F# のコードをDLLにビルド→Unityプロジェクトに自動でコピー→MonoBehaviourのラッパーを自動生成」で快適なF# (on Unity)生活が送れるかと思ったけどImportAssetの待ち時間イライラするかも
— よだ (@n__yoda) 2014, 1月 28
@hamazy @florets1 @kazuhito_m 空いてたら僕は発表します。最近UnityとF# を使ってるのでそういうのはいいかなと思ってます。
— ダニー (@tataminomusi) 2014, 11月 6
UnityでF#使えるんなら使ってやらんこともない(上から目線)
— 自然界 (@mizchi) 2014, 5月 6
次期メジャーバージョンアップで「Unity、F# を開発言語としてサポート」とかならないかなぁ。API がそぐわないとか色々あるんだろうけど…
— Yu I. into VR (@japboy) 2014, 7月 24
えふしゃーぷでUnityは理想的には可能(趣味の範囲で)
— ぜくる (@zecl) 2014, 6月 29
"Unity によりサポートされないコンパイラを使用できる場合があり(例えば F#)" http://t.co/v0xX4sgUxS なので全部F#でイケるが、いろいろ制約あって謎のノウハウが必要なのでメリットよりもデメリットが多いので素人にはお勧めできない
— ぜくる (@zecl) 2014, 5月 17
意外といらっしゃる。もちろんこれですべてではない。
Unity は良くできているゲームエンジンなので、F# でも使いたい!という気持ちはわかりますが、一般的には、F# でゲームを作りたいなら MonoGame あたりを選択する方がかしこいんじゃないでしょうか。はい。とは言え、 身の回りにUnity F# マンがいたら、ぜひとも情報交換などしてみたいですね。
ところで、わたくしごとで恐縮ですが、8か月以上という長いニート期間を終え、 12/1 から株式会社グラニで働いております。みなさんご存じ「最先端のC#技術を使った」ゲーム開発をしている会社です。とてもよい環境で仕事をさせていただいています。ということでわたくし現在東京におりますので、F# 談話室がある際にはぜひ遊びに行きたいです。趣味のF#erからは以上です。