OCTOPATH TRAVELER

Nintendo Switch 版の OCTOPATH TRAVELER をようやくクリアした。オクトパスはタコじゃなくて、8 つの PATH だったのか。

美麗な 2D グラフィックと、その世界観から、サガシリーズでもおかしくない。というか、もはやサガシリーズでは。なんでサガシリーズじゃないの。2D グラフィックの JRPG で、しかもオープンワールドなので、自由にどこにでも行ける。逃げまくりながら最果ての街に行って装備を整え、レベルも上げてから攻略開始する、なんてこともできる。自分が子供の頃に憧れた JRPG の正当進化がここに。

はじめに 8 人のキャラクターの中から主人公を一人選ぶ。クリアしても、選んだキャラクターのエンディングしか見れないのか、と最初不安になったが、他のキャラクターは仲間に加わるし、仲間のエンディングもちゃんと見れるので心配無用だった。好きなキャラを選ぶといい。自分はテリオンが気に入った。こう、厨二心をくすぐるというか…。フィールドコマンド「盗む」のお陰で、店でほとんどアイテム買うことが無かった。

各キャラクターのメインストーリーは、それぞれ4章構成。オルベリクは王道、一方でプリムロゼはだいぶビターで大人な物語だったな。大抵の場合、次の章で求められるレベルがだいぶ離れているので、レベル上げを兼ねて、サブストーリーをこなしたり、他のキャラクターの章を進めることになる。それにしても、全く関連がないように見えた8人の物語が、実は裏で繋がってたとはなぁ。

8人のストーリーは全部クリアしたし、サブストーリーも全部クリアした。完全クリア達成。特に裏ボスは途中でセーブできないのがつらかった。3時間弱かかった。全員レベル75以上にして、最後なのでアイテムや調合も出し惜しみなく使って、ようやく勝利。調合は裏ボス戦で初めてつかったかも。ダブルトマホークがシールド減らすのに大活躍したのが印象深い。あると無いとでは、難易度が段違いだった。

個人的に、往年の、2D な JRPG の理想形だと思う。特にドット絵の進化系を謳った HD-2D が素晴らしすぎる。過去の名作をリメイクするときは全部この手法でやって欲しい。クロノトリガーとか、ライブアライブとか。ライブアライブは、主人公がたくさんいるのは同じなので、ピッタリなのでは。そう思ってしまうくらい、HD-2D のグラフィックは満足度が高かった。

ZIP ファイルを使用した Azure App Service への React 製アプリのデプロイ

React 製 Single Page Application を Azure App Service でホストするために、Kudu のカスタムデプロイスクリプトを書いてデプロイ時にアプリをビルドし、それを express でサーブしていた。

tnakamura.hatenablog.com

でも実は、Kudu のカスタムデプロイスクリプトなんて書かずとも、ローカルでビルドしたものを ZipDeploy すれば OK だった。

# React 製アプリをビルド
npm run build

# ZIP ファイル作成
Compress-Archive -Path .\build\* -DestinationPath build.zip -Force

# Azure CLI で ZIP ファイルをデプロイ
az webapp deployment source config-zip --resource-group <group-name> --name <app-name> --src .\build.zip

IdentityModel を使ってクライアントを OAuth 2.0 の認可コードフローと PKCE に対応させる

PKCE 対応に苦労したので、サンプルコードをメモしておく。

using System;
using System.Diagnostics;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using IdentityModel;
using IdentityModel.Client;

namespace SampleApiClient
{
    class Program
    {
        const string IdentityServerAddress = "IdentityServerのアドレス";
        const string WebApiAddress = "Web API のアドレス";
        const string ClientId = "クライアント ID";
        const string ClientSecret = "クライアントシークレット";
        const string Scope = "API スコープ";

        // 認証に成功した後リダイレクトする URL
        const string RedirectUrl = "http://localhost";

        static async Task Main(string[] args)
        {
            var tokenClient = new HttpClient
            {
                BaseAddress = new Uri(IdentityServerAddress),
            };

            var disco = await tokenClient.GetDiscoveryDocumentAsync();
            if (disco.IsError)
            {
                Console.WriteLine(disco.Error);
                return;
            }

            // PKCE 対応
            var codeVerifier = CryptoRandom.CreateUniqueId();
            string challenge;
            using (var sha256 = SHA256.Create())
            {
                var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
                challenge = Base64Url.Encode(challengeBytes);
            }

            // Web ブラウザでログインページを表示
            // ログインに成功したあと、クライアントへの Web API 利用許可も行ったら、
            // http://localhost にリダイレクトする。
            // Web ブラウザのアドレスバーの URL から認証コードを取り出せる。
            var nonce = CryptoRandom.CreateUniqueId();
            var request = new RequestUrl(disco.AuthorizeEndpoint);
            var authorizeUrl = request.CreateAuthorizeUrl(
                clientId: ClientId,
                responseType: OidcConstants.ResponseTypes.Code,
                scope: Scope,
                redirectUri: RedirectUrl,
                nonce: nonce,
                codeChallenge: challenge,
                codeChallengeMethod: OidcConstants.CodeChallengeMethods.Sha256);
            var escapedUrl = authorizeUrl.Replace("&", "^&");
            Process.Start(new ProcessStartInfo("cmd", $"/c start {escapedUrl}")
            {
                CreateNoWindow = true,
            });

            // 認証コードを使ってアクセストークンを取得
            Console.Write("AuthorizationCode:");
            var code = Console.ReadLine();
            var codeRequest = new AuthorizationCodeTokenRequest
            {
                Address = disco.TokenEndpoint,

                ClientId = ClientId,
                ClientSecret = ClientSecret,
                Code = code,
                RedirectUri = RedirectUrl,
                CodeVerifier = codeVerifier,
            };
            var tokenResponse = await tokenClient.RequestAuthorizationCodeTokenAsync(codeRequest);
            if (tokenResponse.IsError)
            {
                Console.WriteLine(tokenResponse.Error);
                return;
            }

            // Web API を呼び出すクライアントは、
            // アクセストークンを取得するクライアントとは別
            var apiClient = new HttpClient
            {
                BaseAddress = new Uri(WebApiAddress),
            };

            // アクセストークンを設定
            apiClient.SetBearerToken(tokenResponse.AccessToken);

            // データを取得
            var response = await apiClient.GetAsync(
                "/api/products");
            response.EnsureSuccessStatusCode();
            Console.WriteLine(await response.Content.ReadAsStringAsync());

            Console.WriteLine("Enter で終了");
            Console.ReadLine();
        }
    }
}

Windows サービスを Network Service アカウントで動かす PowerShell スクリプト

Windows サービスとして動くようにした ASP.NET Core アプリを、Network Service アカウントで動かすための PowerShell スクリプトを書いた。

$serviceName = "Windows サービス名"
$exePath = "Windows サービスとして動かす exeのパス"
$displayName = "表示名"
$description = "説明"

$password = ConvertTo-SecureString "dummy" -AsPlainText -Force 
$credential = New-Object System.Management.Automation.PSCredential ("NT AUTHORITY\NETWORK SERVICE", $password)

New-Service -Name $serviceName -BinaryPathName $exePath -DisplayName $displayName -Description $description -StartupType Automatic -Credential $credential

& sc.exe failure $serviceName reset= 86400 actions= restart/60000/restart/60000/restart/60000

Start-Service -Name $serviceName

おまけで、アンインストールも。

$serviceName = "Windows サービス名"

Stop-Service -Name $serviceName

& sc.exe delete $serviceName

Kestrel デフォルトのエンドポイントのままセルフホストしてハマった

Kestrel のエンドポイントは、デフォルトだと http://localhost:5000;https://localhost:5001。デフォルトのままでいいやと、Windows Server 2016 の VMWindows サービスとしてセルフホストしたら、VM 外からアクセスできなくてハマった。

VM 内から http://localhost:5000http://127.0.0.1:5000 ならアクセスできた。http://VMのプライベートIPアドレス:5000 だとアクセスできない。

UseUrls メソッドか --urls コマンドライン引数で、http://*:5000;https://*:5001 を指定する必要があったようだ。WCF だと localhost で上手くいっていたので、まったく怪しまなかった。こんな初歩的なことでハマるとは…。いつも ASP.NET Core アプリのホストは App Service ばっかりだったので、App Service がいかに至れり尽くせりなのかを実感した。

ASP.NET Core アプリを Windows サービスでホストする場合はカレントディレクトリの変更を忘れてはいけない

大人の事情で、ASP.NET Core アプリを Microsoft.AspNetCore.Hosting.WindowsServices を使って Windows サービスでホストしていたんだけど、カレントディレクトリの変更は不要と思ってコードを省略したら、ヒドイ目にあった。

public class Program
{
    public static void Main(string[] args)
    {
        var isService = !(Debugger.IsAttached || args.Contains("--console"));
        
        // これを忘れたらダメ!ゼッタイ!忘れたらヒドイ目にあう、かも。
        if (isService)
        {
            var pathToExe = Process.GetCurrentProcess().MainModule.FileName;
            var pathToContentRoot = Path.GetDirectoryName(pathToExe);
            Directory.SetCurrentDirectory(pathToContentRoot);
        }

        var builder = CreateWebHostBuilder(
            args.Where(arg => arg != "--console").ToArray());

        var host = builder.Build();

        if (isService)
        {
            host.RunAsService();
        }
        else
        {
            host.Run();
        }
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .ConfigureLogging((hostingContext, logging) =>
            {
                logging.AddEventLog();
            })
            .UseStartup<Startup>();
}

ASP.NET Core Web API を IdentityServer4 で保護しようとしていて、appsettings.json に IdentityServer4 のアドレスを書いていた。カレントディレクトリの変更を忘れたばかりに、Windows サービスとして動かしたときだけ appsettings.json が読み込まれず。

IdentityServer4 のアドレスを Microsoft.AspNetCore.Authentication.JwtBearer の構成時に指定できなかったので、 IdentityServer4 から署名キーを入手することができなかったようで、アクセストークンの検証時に The signature key was not found が返ってきてしまっていた。

気付くまで 2 日かかったよ…。

ちなみに、 .NET 3.1 から Microsoft.Extensions.Hosting.WindowsServices が推奨されていて、そっちを使うと UseWindowsService 内でカレントディレクトリを切り替えてくれる。早く .NET Core 3.1 か、.NET 6 に移行しなければ。

Age++

38歳になった。もう、完全にアラフォー。まごうことなきアラフォー。38歳ともなれば、精神的にだいぶ大人になっているもんだと思っていたけど、特に変わった気はしない。こんなもんか。

今年は本業が忙し過ぎて、個人開発がまったくやれていない。むしろ、ストレス解消のために、空いた時間はゲームをやっている。ゲームで遊んでいる最中は、頭を空っぽにできて良い感じ。日中は本業、夜は個人開発という生活だと、脳を休める時間がないので、こういった時間は重要だ。

その個人開発では Flutter が凄く気になっていたけど、触ってみたらイマイチ Dart が好きになれず。でも独自描画なので、Xamarin.Forms や ReactNative よりも、iOSAndroid で同じ UI が実現しやすいのは魅力的。でも Dart が…。という感じでブレブレ。

独自描画といえば、Switch で遊んでいる影響もあり、Unity もかなり気になっている。Unity なら C# だし。個人開発では、まったく新しい技術に挑戦するよりは、今までのスキルを生かせる技術を選ぶほうが、エタりにくいと思う。ゲームクリエイターは幼少期の夢でもあったので、Unity アリだな。ただ、カジュアルゲームレッドオーシャンか。むしろ血の海だ。幼少期の憧れ JRPG は個人でやるには規模が大きすぎるな。という感じで、こちらでもブレブレだ。

個人開発以外だと、コロナのせいで旅行も外食も行けなくなって、楽しみがマンガとゲームくらいしかない。店で食べるのが一番旨いだろう、というこだわりがあるので、テイクアウトは性に合わない。外出はちょっとしたレジャー兼ねていたからなぁ。ワクチン接種し終わるまでは行きづらい。

2021 年後半も進捗ダメかもな。