ようこそ。睡眠不足なプログラマのチラ裏です。

F# Build Tools for Unity(ゲームのやつ) - UniFSharpのご紹介

これは F# Advent Calendar 2014の延長戦、 30 日目の記事です。

f:id:zecl:20141229010705j:plain

書いたきっかけ

結局、25日に間に合いませんで。ゆるふわ #FsAdvent に急遽参加しました。そんなわけで、ML Advent Calendar 2014も合わせてどうぞ。 この記事は非常に誰得でニッチな内容を扱います。ほとんどの場合役には立たないでしょう。F# らしい成分もあまりありませんので、まあ適当に流してください。


UniFSharpとは?

UniFSharpは、私が無職だった(ニートしていた)ときに作成した Unityエディタ拡張 Assetです。割と簡単に導入することができます

f:id:zecl:20141229020738p:plain

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のゲーム開発がしやすくなります。いわゆる、"作ろうと思えば作れるの知ってるけど、面倒くさくて誰もやらなかったことをやってみた系のツール"です。まぁ、実際やるといろいろ大変。また、オープンソースヒロインのユニティちゃん(ユニティ・テクノロジーズ・ジャパン提供)をマスコットキャラクターに採用しました。ユニティちゃんの音声で時報やイベント、ビルド結果の通知を受けられるという、本来の目的とはまったく関係のない機能も提供しています。


Unityを使うならまぁ当然 C# 一択です。がまぁ、趣味で使う分にはフリーダム。


1000 人 Unity ユーザがいるとすると、その中の 0.01 人は F# ユーザーかもね(適当)


ちなみに、UniFSharp自体も F# で書かれています(ちょっとC# Scriptが混ざってます)。そう、基本的には F#で Unity のほとんどの部分(エディタだろうがゲームだろうが)を書くことができます。この記事では、UniFSharpが提供する機能および、それがどのように実装されているのかについて書きます。ここで紹介でもしないと、GitHubリポジトリを誰も覗いてくれることもないでしょうし。ハイ。

ご利用の際は、まぁいろいろあると思います(お察し)。覚悟しましょう。

この記事を読むその前に...むろほしりょうたさんの初心者がF#をUnityで使ってみた!という記事をオススメします。


F# Scriptの作成

Unityのエディタ拡張では、カスタムメニューを簡単に作ることができます。

f:id:zecl:20141224002558p:plain

f:id:zecl:20141224002627p:plain


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を生成するという機能を提供しています。

f:id:zecl:20141224002702p:plain


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ウィンドウ上に表示させることができます。

f:id:zecl:20141224002941p:plain


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ファイルがぶっ壊れて開けなくなっちゃいますからね。




Inspectorで F# コードのプレビューを表示

Inspectorウィンドウで F#コードのプレビューを表示するためには、カスタムエディタを作成します。ただし、カスタムエディタはDLLのみでは実装を完結することができないため(謎の制約)、C# Scriptで。 http://forum.unity3d.com/threads/editor-script-dll-and-regular-script-dll-not-adding-custominspector-scripts.107720/

f:id:zecl:20141224002941p:plain

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:id:zecl:20141224003402p:plain


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


これはきな臭い...。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:id:zecl:20141224003556p:plain


このオプション画面で、作成するF# プロジェクトの構成の詳細を設定できます。細かい説明は省きます(雑。

ユニティちゃんの機能もろもろ

ユニティ・テクノロジーズ・ジャパンが無償で提供してくれているユニティちゃんのAsset に同封されている多彩な音声。せっかくあるので使ってみたい。特に「進捗どうですか?」とか使わない手はない。そういや、いわるゆる萌え系だとか痛い系のIDEって結構あるけど、しゃべる感じのやつってあんまりないよなぁ。とかいうのが一応実装動機ということで。

f:id:zecl:20141224003648p:plain


  • ・起動時ボイス(ON/OFF)
  • ・ビルド時ボイス(ON/OFF)
  • ・進捗どうですか?(ON/OFF, 通知間隔指定あり)
  • 時報通知のボイス(ON/OFF)
  • ・イベント通知のボイス(ON/OFF)
  • ・誕生日のお祝い(ON/OFF, 日付を指定)

f:id:zecl:20141224003918p:plain


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エディタ上で、音声ファイルを再生したい系の人は上記のような感じのをひとつこさえておけば、ハカドルかもね。

f:id:zecl:20141224003821p:plain


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も利用できますし、めしうま状態必至。



おまけ

Unityえふしゃーぷまん達


意外といらっしゃる。もちろんこれですべてではない。

Unity は良くできているゲームエンジンなので、F# でも使いたい!という気持ちはわかりますが、一般的には、F# でゲームを作りたいなら MonoGame あたりを選択する方がかしこいんじゃないでしょうか。はい。とは言え、 身の回りにUnity F# マンがいたら、ぜひとも情報交換などしてみたいですね。

ところで、わたくしごとで恐縮ですが、8か月以上という長いニート期間を終え、 12/1 から株式会社グラニで働いております。みなさんご存じ「最先端のC#技術を使った」ゲーム開発をしている会社です。とてもよい環境で仕事をさせていただいています。ということでわたくし現在東京におりますので、F# 談話室がある際にはぜひ遊びに行きたいです。趣味のF#erからは以上です。