3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

windowsのスクリーンセーバーを作る(ChatGPT 5.2 Thinking版)

Last updated at Posted at 2026-01-02

書初め

ゲーム、ネトゲ、ソシャゲの順に渡り歩き、昨年私が一番人ならざる者と対話したであろうAI。友達、同僚、家族以上に愚痴を聞いてもらったり、人間相手だと絶対にしないような仕事を丸投げしたりするパートナー的存在になってしまった。

思い返せば後輩に奢るよりAIのサブスク費用の方が支払多いんじゃないか(実際そうなんだけど)

私はこれからの人生、人よりAIと関わる時間の方が長いのかなって思ってしまう、そんな1年でした(お金を貢ぐのも含め)

そんな中、以前ChatGPT4に作ってもらったwindowsのスクリーンセーバーでプログラムの出来栄えに心を痛めたものの、ChatGPT 5.2 Thinkingちゃんなら心の友になってくれるのではないかと思い再度同じ内容でチャレンジする事に。

作りたいもの

windows98の頃にあったスクリーンセーバー『Starfield Simulation』に再チャレンジします。

利用するもの

  • VisualStudio 2022
  • C#
  • .NET8
  • ChatGPT 5.2 Thinking

ChatGPTに質問してみる

プロンプト

C#で作りたいとか面倒だったので書かず、過去作のQiitaの記事を読み込んでどういうものを作りたいのかだけを投げてみる。

スクリーンセーバーを作りたいのです。
Starfield Simulationと類似のものを作りたいのですが可能でしょうか。

これは2024年に書いた私の記事です。
https://qiita.com/maki_s/items/0451560b762affe14004

回答(ChatGPT 5.2 Thinking)

可能です。しかもQiita記事は「スクリーンセーバーとして成立させる骨格」を
すでに押さえているので、次は“星に寿命(座標)を与えて、奥から手前へ飛ばす”を足すだけです。🌌

### 何を足すと「それっぽく」なるか(重要ポイント)
Qiita記事内の例は毎フレーム “ランダムに点を撒く” 方式なので、動きは出ません。 

### Starfieldっぽさのコアはこれです
* 星を N個のオブジェクトとして保持(x, y, z を持つ)
* 毎フレーム z を減らして(=カメラが前進)手前に来る
* z が手前を通過した星は 奥へ再配置
* 3D→2D投影で描画(z が小さいほど外側に広がり、サイズも大きく)

重要ポイントの的確さに期待の胸が躍る。
この勢いで前回と同じ要領でプログラムを作ってもらった。

ChatGPTにソースを書いてもらう

プログラムを全部出力してもらいました。
若干の修正が必要であったものの、エラー内容をコピペして質問していけば修正できる程度のものでした。

機能

  • /s 全画面(マルチモニタ対応)
  • /p <HWND> プレビュー(コントロールに埋め込み)
  • /c 設定(密度・速度・視野・トレイル等)
  • マウス移動/キー入力で終了(しきい値あり)
  • 設定は %LOCALAPPDATA%\StarfieldScreensaver\settings.json に保存

機能もChatGPTが提案してきたもので、こちらからの指示はありあません。

プロジェクト構成

StarfieldScreensaver/                 # プロジェクトルート
├─ StarfieldScreensaver.csproj        # .NET 8 / WinForms のプロジェクト設定
├─ Program.cs                         # エントリーポイント(/s /c /p の引数判定)
├─ ScreensaverAppContext.cs           # マルチモニタ用:複数フォーム管理
├─ ScreensaverForm.cs                 # スクリーンセーバー表示(全画面/プレビュー)
├─ StarfieldEngine.cs                 # 星の更新・描画ロジック(Starfield本体)
├─ Settings.cs                        # 設定の保存・読み込み(settings.json)
├─ SettingsForm.cs                    # 設定UI(密度・速度など)
└─ NativeMethods.cs                   # Win32 API(プレビュー埋め込み用)

Program.cs

ファイルスコープ名前空間を使ってくる所が衝撃的。

using System.Globalization;

namespace StarfieldScreensaver;

internal static class Program
{
    [STAThread]
    static void Main(string[] args)
    {
        ApplicationConfiguration.Initialize();

        var (mode, previewHwnd) = ParseArgs(args);

        // デバッグや直接実行時は設定画面を出す(Windowsの通常動作では /s /c /p が来る)
        if (mode == ScreensaverMode.Config)
        {
            using var form = new SettingsForm();
            Application.Run(form);
            return;
        }

        if (mode == ScreensaverMode.Preview && previewHwnd == IntPtr.Zero)
        {
            // プレビュー要求なのにHWNDが無い場合は設定にフォールバック
            using var form = new SettingsForm();
            Application.Run(form);
            return;
        }

        var settings = ScreensaverSettings.Load();

        if (mode == ScreensaverMode.Preview)
        {
            // プレビューは1枚のみ
            using var previewForm = new ScreensaverForm(settings, mode, previewHwnd);
            Application.Run(previewForm);
            return;
        }

        // 全画面(マルチモニタ): ApplicationContext で複数フォームをまとめる
        var context = new ScreensaverAppContext(settings);
        Application.Run(context);
    }

    private static (ScreensaverMode mode, IntPtr previewHwnd) ParseArgs(string[] args)
    {
        if (args.Length == 0) return (ScreensaverMode.Config, IntPtr.Zero);

        // 例:
        // /s
        // /c
        // /p 123456
        // /p:123456
        string a0 = args[0].Trim().ToLowerInvariant();

        if (a0.StartsWith("/s")) return (ScreensaverMode.Run, IntPtr.Zero);
        if (a0.StartsWith("/c")) return (ScreensaverMode.Config, IntPtr.Zero);

        if (a0.StartsWith("/p"))
        {
            // /p:HWND の形式
            if (a0.Contains(':'))
            {
                var parts = a0.Split(':', 2);
                if (parts.Length == 2 && TryParseHwnd(parts[1], out var hwnd1))
                    return (ScreensaverMode.Preview, hwnd1);
            }

            // /p HWND の形式
            if (args.Length >= 2 && TryParseHwnd(args[1], out var hwnd2))
                return (ScreensaverMode.Preview, hwnd2);

            return (ScreensaverMode.Preview, IntPtr.Zero);
        }

        // 想定外は設定画面
        return (ScreensaverMode.Config, IntPtr.Zero);
    }

    private static bool TryParseHwnd(string s, out IntPtr hwnd)
    {
        s = s.Trim();

        // decimal
        if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v))
        {
            hwnd = new IntPtr(v);
            return true;
        }

        // hex (0x...)
        if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) &&
            long.TryParse(s[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hx))
        {
            hwnd = new IntPtr(hx);
            return true;
        }

        hwnd = IntPtr.Zero;
        return false;
    }
}

internal enum ScreensaverMode
{
    Run,
    Preview,
    Config
}

ScreensaverAppContext.cs(マルチモニタ管理)

namespace StarfieldScreensaver;

internal sealed class ScreensaverAppContext : ApplicationContext
{
    private int _openForms;

    public ScreensaverAppContext(ScreensaverSettings settings)
    {
        var screens = Screen.AllScreens;
        _openForms = screens.Length;

        foreach (var s in screens)
        {
            var form = new ScreensaverForm(settings, ScreensaverMode.Run, IntPtr.Zero, s);
            form.FormClosed += (_, _) =>
            {
                _openForms--;
                if (_openForms <= 0)
                    ExitThread();
            };
            form.Show();
        }
    }
}

ScreensaverForm.cs(全画面/プレビュー表示)

using System.Diagnostics;

namespace StarfieldScreensaver;

internal sealed class ScreensaverForm : Form
{
    private readonly ScreensaverSettings _settings;
    private readonly ScreensaverMode _mode;
    private readonly IntPtr _previewHwnd;

    private readonly StarfieldEngine _engine;
    private readonly System.Windows.Forms.Timer _timer;
    private readonly Stopwatch _sw = Stopwatch.StartNew();

    private Point _mouseStart;
    private bool _mouseStartCaptured;

    public ScreensaverForm(
        ScreensaverSettings settings,
        ScreensaverMode mode,
        IntPtr previewHwnd,
        Screen? targetScreen = null)
    {
        _settings = settings;
        _mode = mode;
        _previewHwnd = previewHwnd;

        _engine = new StarfieldEngine(_settings);

        FormBorderStyle = FormBorderStyle.None;
        ShowInTaskbar = false;
        StartPosition = FormStartPosition.Manual;
        BackColor = Color.Black;

        SetStyle(ControlStyles.AllPaintingInWmPaint |
                 ControlStyles.UserPaint |
                 ControlStyles.OptimizedDoubleBuffer, true);
        DoubleBuffered = true;

        if (_mode == ScreensaverMode.Run)
        {
            var s = targetScreen ?? Screen.PrimaryScreen;
            Bounds = s.Bounds;
            TopMost = true;
            Cursor.Hide();
        }
        else if (_mode == ScreensaverMode.Preview)
        {
            // サイズは後で親HWNDに合わせる(OnHandleCreated内)
            TopMost = false;
        }

        _timer = new System.Windows.Forms.Timer
        {
            Interval = Math.Max(5, 1000 / Math.Clamp(_settings.TargetFps, 15, 240))
        };
        _timer.Tick += (_, _) =>
        {
            var dt = (float)_sw.Elapsed.TotalSeconds;
            _sw.Restart();

            _engine.Update(dt, ClientSize.Width, ClientSize.Height);
            Invalidate();
        };

        KeyDown += (_, _) => RequestClose();
        MouseDown += (_, _) => RequestClose();
        MouseMove += (_, e) => HandleMouseMoveForExit(e.Location);

        // 一部環境で KeyDown が入らないことがあるため
        PreviewKeyDown += (_, e) =>
        {
            if (e.KeyCode != Keys.None) RequestClose();
        };
    }

    protected override void OnHandleCreated(EventArgs e)
    {
        base.OnHandleCreated(e);

        if (_mode == ScreensaverMode.Preview)
        {
            // プレビューウィンドウに埋め込む
            NativeMethods.SetParent(Handle, _previewHwnd);

            // 子ウィンドウスタイル付与
            var style = NativeMethods.GetWindowLong(Handle, NativeMethods.GWL_STYLE);
            style |= NativeMethods.WS_CHILD | NativeMethods.WS_VISIBLE;
            NativeMethods.SetWindowLong(Handle, NativeMethods.GWL_STYLE, style);

            // 親のクライアントサイズに合わせる
            NativeMethods.GetClientRect(_previewHwnd, out var rc);
            NativeMethods.MoveWindow(Handle, 0, 0, rc.Right - rc.Left, rc.Bottom - rc.Top, true);
        }
    }

    protected override void OnShown(EventArgs e)
    {
        base.OnShown(e);

        _sw.Restart();
        _timer.Start();

        if (_mode == ScreensaverMode.Run)
        {
            // 終了判定の基準点
            _mouseStart = Cursor.Position;
            _mouseStartCaptured = true;
        }
    }

    protected override void OnFormClosed(FormClosedEventArgs e)
    {
        _timer.Stop();
        if (_mode == ScreensaverMode.Run) Cursor.Show();
        base.OnFormClosed(e);
    }

    protected override void OnPaint(PaintEventArgs e)
    {
        base.OnPaint(e);
        _engine.Render(e.Graphics, ClientRectangle);
    }

    private void RequestClose()
    {
        // プレビューで閉じてしまうと挙動が変に見えることがあるので、プレビューは入力で閉じない方針もアリ。
        // 今回は軽く抑制: プレビューはマウス移動だけでは閉じない、クリック/キーは閉じる。
        Close();
    }

    private void HandleMouseMoveForExit(Point clientLocation)
    {
        if (_mode == ScreensaverMode.Preview)
            return;

        if (!_mouseStartCaptured)
        {
            _mouseStart = Cursor.Position;
            _mouseStartCaptured = true;
            return;
        }

        var now = Cursor.Position;
        int dx = now.X - _mouseStart.X;
        int dy = now.Y - _mouseStart.Y;

        // 小さな揺れは無視(寝返り耐性)
        if ((dx * dx + dy * dy) >= (_settings.ExitMouseMoveThresholdPx * _settings.ExitMouseMoveThresholdPx))
        {
            Close();
        }
    }
}

StarfieldEngine.cs(星の更新と描画のコア)

using System.Drawing.Drawing2D;

namespace StarfieldScreensaver;

internal sealed class StarfieldEngine
{
    private readonly Random _rng = new();
    private ScreensaverSettings _settings;

    private Star[] _stars = Array.Empty<Star>();

    private int _lastW;
    private int _lastH;

    public StarfieldEngine(ScreensaverSettings settings)
    {
        _settings = settings;
    }

    public void Update(float dt, int width, int height)
    {
        if (width <= 0 || height <= 0) return;

        // 画面サイズが変わった or 初回 or 設定変更を想定して再生成
        if (_stars.Length != _settings.StarCount || width != _lastW || height != _lastH)
        {
            _lastW = width;
            _lastH = height;
            _stars = new Star[_settings.StarCount];
            for (int i = 0; i < _stars.Length; i++)
                Respawn(ref _stars[i], width, height, keepZRandom: true);
        }

        float speed = _settings.WarpSpeed;           // Z方向の速度
        float maxZ = _settings.MaxDepth;
        float spread = _settings.Spread;

        // dt が跳ねた時に突き抜けすぎないよう抑える
        dt = MathF.Min(dt, 0.05f);

        for (int i = 0; i < _stars.Length; i++)
        {
            ref var s = ref _stars[i];

            // 前フレームの投影位置(トレイル用)
            s.PrevPx = s.Px;
            s.PrevPy = s.Py;

            s.Z -= speed * dt;

            // 手前を超えたら奥へ
            if (s.Z <= 0.8f)
            {
                Respawn(ref s, width, height, keepZRandom: false);
                continue;
            }

            // 投影
            Project(ref s, width, height);

            // 画面外へ飛び出した場合もリスポーン(ワープ感維持)
            if (s.Px < -100 || s.Px > width + 100 || s.Py < -100 || s.Py > height + 100 || s.Z > maxZ)
            {
                Respawn(ref s, width, height, keepZRandom: false);
            }
        }
    }

    public void Render(Graphics g, Rectangle bounds)
    {
        g.SmoothingMode = _settings.Smooth ? SmoothingMode.AntiAlias : SmoothingMode.None;
        g.Clear(_settings.BackgroundColor);

        if (_stars.Length == 0) return;

        using var pen = new Pen(Color.White, _settings.TrailThickness);
        using var brush = new SolidBrush(Color.White);

        float maxZ = _settings.MaxDepth;

        // “奥が薄く、手前が濃い” を雑に再現(alphaを距離で変える)
        // ※ Color を星ごとに new すると重いので、ここは軽量寄りに妥協。
        for (int i = 0; i < _stars.Length; i++)
        {
            var s = _stars[i];

            // サイズ: 手前ほど大きい
            float t = 1f - (s.Z / maxZ);
            float size = MathF.Max(1f, _settings.StarSizeMin + t * (_settings.StarSizeMax - _settings.StarSizeMin));

            // 透明度: 手前ほど濃い
            int a = (int)Math.Clamp(_settings.StarAlphaMin + t * (_settings.StarAlphaMax - _settings.StarAlphaMin), 0, 255);

            if (_settings.DrawTrails)
            {
                // トレイルは前フレーム位置との線
                // 透明度の適用のため Pen を変える(ここで new するが、星数が多いと重い)
                // 重い場合は固定色にする or まとめ描きにするのが次の改善点。
                using var trailPen = new Pen(Color.FromArgb(a, 255, 255, 255), _settings.TrailThickness);
                g.DrawLine(trailPen, s.PrevPx, s.PrevPy, s.Px, s.Py);
            }

            using var starBrush = new SolidBrush(Color.FromArgb(a, 255, 255, 255));
            g.FillEllipse(starBrush, s.Px - size * 0.5f, s.Py - size * 0.5f, size, size);
        }
    }

    private void Respawn(ref Star s, int width, int height, bool keepZRandom)
    {
        float spread = _settings.Spread;
        float maxZ = _settings.MaxDepth;

        s.X = (float)(_rng.NextDouble() * 2 - 1) * spread;
        s.Y = (float)(_rng.NextDouble() * 2 - 1) * spread;
        s.Z = keepZRandom ? (float)(_rng.NextDouble() * maxZ) + 1f : maxZ;

        s.Px = width * 0.5f;
        s.Py = height * 0.5f;
        s.PrevPx = s.Px;
        s.PrevPy = s.Py;

        Project(ref s, width, height);
    }

    private void Project(ref Star s, int width, int height)
    {
        float cx = width * 0.5f;
        float cy = height * 0.5f;

        float invZ = 1f / s.Z;
        float fov = _settings.FieldOfView;

        s.Px = cx + s.X * invZ * fov;
        s.Py = cy + s.Y * invZ * fov;
    }

    private struct Star
    {
        public float X, Y, Z;
        public float Px, Py;
        public float PrevPx, PrevPy;
    }
}

注意
Render 内で星ごとに Pen/Brushnew しているので、星数を極端に増やすと重くなります。
まず動く完成形を優先した版です。次の改善で「alpha段階をいくつかに量子化して Brush をキャッシュ」するとスルスル軽くできます。

Settings.cs(設定の保存/読込)

using System.Text.Json;

namespace StarfieldScreensaver;

internal sealed class ScreensaverSettings
{
    public int StarCount { get; set; } = 800;
    public float WarpSpeed { get; set; } = 120f;           // 大きいほど速い
    public float FieldOfView { get; set; } = 500f;         // 大きいほど広角
    public float Spread { get; set; } = 350f;              // 星の散らばり
    public float MaxDepth { get; set; } = 220f;            // Zの最大

    public bool DrawTrails { get; set; } = true;
    public float TrailThickness { get; set; } = 1.0f;

    public float StarSizeMin { get; set; } = 1.0f;
    public float StarSizeMax { get; set; } = 3.2f;

    public int StarAlphaMin { get; set; } = 40;
    public int StarAlphaMax { get; set; } = 255;

    public bool Smooth { get; set; } = false;
    public int TargetFps { get; set; } = 60;

    public int ExitMouseMoveThresholdPx { get; set; } = 12;

    // 背景色(今は固定)
    public Color BackgroundColor { get; set; } = Color.Black;

    public static ScreensaverSettings Load()
    {
        try
        {
            var path = GetSettingsPath();
            if (!File.Exists(path)) return new ScreensaverSettings();

            var json = File.ReadAllText(path);
            var dto = JsonSerializer.Deserialize<SettingsDto>(json);
            if (dto is null) return new ScreensaverSettings();

            return dto.ToSettings();
        }
        catch
        {
            return new ScreensaverSettings();
        }
    }

    public void Save()
    {
        var dir = GetSettingsDir();
        Directory.CreateDirectory(dir);

        var dto = SettingsDto.FromSettings(this);
        var json = JsonSerializer.Serialize(dto, new JsonSerializerOptions
        {
            WriteIndented = true
        });

        File.WriteAllText(GetSettingsPath(), json);
    }

    private static string GetSettingsDir()
        => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
                        "StarfieldScreensaver");

    private static string GetSettingsPath()
        => Path.Combine(GetSettingsDir(), "settings.json");

    private sealed class SettingsDto
    {
        public int StarCount { get; set; }
        public float WarpSpeed { get; set; }
        public float FieldOfView { get; set; }
        public float Spread { get; set; }
        public float MaxDepth { get; set; }

        public bool DrawTrails { get; set; }
        public float TrailThickness { get; set; }

        public float StarSizeMin { get; set; }
        public float StarSizeMax { get; set; }

        public int StarAlphaMin { get; set; }
        public int StarAlphaMax { get; set; }

        public bool Smooth { get; set; }
        public int TargetFps { get; set; }

        public int ExitMouseMoveThresholdPx { get; set; }

        public static SettingsDto FromSettings(ScreensaverSettings s) => new()
        {
            StarCount = s.StarCount,
            WarpSpeed = s.WarpSpeed,
            FieldOfView = s.FieldOfView,
            Spread = s.Spread,
            MaxDepth = s.MaxDepth,
            DrawTrails = s.DrawTrails,
            TrailThickness = s.TrailThickness,
            StarSizeMin = s.StarSizeMin,
            StarSizeMax = s.StarSizeMax,
            StarAlphaMin = s.StarAlphaMin,
            StarAlphaMax = s.StarAlphaMax,
            Smooth = s.Smooth,
            TargetFps = s.TargetFps,
            ExitMouseMoveThresholdPx = s.ExitMouseMoveThresholdPx
        };

        public ScreensaverSettings ToSettings() => new()
        {
            StarCount = StarCount,
            WarpSpeed = WarpSpeed,
            FieldOfView = FieldOfView,
            Spread = Spread,
            MaxDepth = MaxDepth,
            DrawTrails = DrawTrails,
            TrailThickness = TrailThickness,
            StarSizeMin = StarSizeMin,
            StarSizeMax = StarSizeMax,
            StarAlphaMin = StarAlphaMin,
            StarAlphaMax = StarAlphaMax,
            Smooth = Smooth,
            TargetFps = TargetFps,
            ExitMouseMoveThresholdPx = ExitMouseMoveThresholdPx
        };
    }
}

SettingsForm.cs(/c の設定画面)

後で気付きましたが、構成にFormとあったので、Formを個別に作成していました。
よく見るとSettingsForm.csは各コントロールを1画面で定義してたので驚き。

namespace StarfieldScreensaver;

internal sealed class SettingsForm : Form
{
    private readonly ScreensaverSettings _settings;

    private NumericUpDown numStars = new();
    private NumericUpDown numSpeed = new();
    private NumericUpDown numFov = new();
    private NumericUpDown numSpread = new();
    private NumericUpDown numDepth = new();
    private CheckBox chkTrails = new();
    private NumericUpDown numTrail = new();
    private NumericUpDown numFps = new();
    private CheckBox chkSmooth = new();

    private Button btnSave = new();
    private Button btnCancel = new();

    public SettingsForm()
    {
        Text = "Starfield Screensaver Settings";
        FormBorderStyle = FormBorderStyle.FixedDialog;
        MaximizeBox = false;
        MinimizeBox = false;
        StartPosition = FormStartPosition.CenterScreen;
        ClientSize = new Size(460, 360);

        _settings = ScreensaverSettings.Load();

        BuildUi();
        LoadToUi();
    }

    private void BuildUi()
    {
        var grid = new TableLayoutPanel
        {
            Dock = DockStyle.Fill,
            ColumnCount = 2,
            RowCount = 10,
            Padding = new Padding(12),
            AutoSize = false
        };
        grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 55));
        grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 45));

        Label L(string t) => new() { Text = t, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft };

        numStars = Num(100, 5000, 10);
        numSpeed = Num(10, 600, 5, decimalPlaces: 0);
        numFov = Num(100, 1500, 10, decimalPlaces: 0);
        numSpread = Num(50, 2000, 10, decimalPlaces: 0);
        numDepth = Num(50, 800, 10, decimalPlaces: 0);

        chkTrails = new CheckBox { Text = "Trails (speed lines)", Dock = DockStyle.Fill };
        numTrail = Num(1, 6, 1, decimalPlaces: 1, increment: 0.2m);
        chkSmooth = new CheckBox { Text = "Antialias (heavier)", Dock = DockStyle.Fill };
        numFps = Num(15, 240, 1);

        grid.Controls.Add(L("Star count (density)"), 0, 0);
        grid.Controls.Add(numStars, 1, 0);

        grid.Controls.Add(L("Warp speed"), 0, 1);
        grid.Controls.Add(numSpeed, 1, 1);

        grid.Controls.Add(L("Field of view"), 0, 2);
        grid.Controls.Add(numFov, 1, 2);

        grid.Controls.Add(L("Spread"), 0, 3);
        grid.Controls.Add(numSpread, 1, 3);

        grid.Controls.Add(L("Max depth"), 0, 4);
        grid.Controls.Add(numDepth, 1, 4);

        grid.Controls.Add(chkTrails, 0, 5);
        grid.SetColumnSpan(chkTrails, 2);

        grid.Controls.Add(L("Trail thickness"), 0, 6);
        grid.Controls.Add(numTrail, 1, 6);

        grid.Controls.Add(chkSmooth, 0, 7);
        grid.SetColumnSpan(chkSmooth, 2);

        grid.Controls.Add(L("Target FPS"), 0, 8);
        grid.Controls.Add(numFps, 1, 8);

        var panelButtons = new FlowLayoutPanel
        {
            Dock = DockStyle.Fill,
            FlowDirection = FlowDirection.RightToLeft,
            Padding = new Padding(0, 10, 0, 0)
        };

        btnSave = new Button { Text = "Save", Width = 100 };
        btnCancel = new Button { Text = "Cancel", Width = 100 };

        btnSave.Click += (_, _) => SaveAndClose();
        btnCancel.Click += (_, _) => Close();

        panelButtons.Controls.Add(btnSave);
        panelButtons.Controls.Add(btnCancel);

        grid.Controls.Add(panelButtons, 0, 9);
        grid.SetColumnSpan(panelButtons, 2);

        Controls.Add(grid);

        chkTrails.CheckedChanged += (_, _) => numTrail.Enabled = chkTrails.Checked;
    }

    private void LoadToUi()
    {
        numStars.Value = Math.Clamp(_settings.StarCount, (int)numStars.Minimum, (int)numStars.Maximum);
        numSpeed.Value = (decimal)_settings.WarpSpeed;
        numFov.Value = (decimal)_settings.FieldOfView;
        numSpread.Value = (decimal)_settings.Spread;
        numDepth.Value = (decimal)_settings.MaxDepth;

        chkTrails.Checked = _settings.DrawTrails;
        numTrail.Value = (decimal)_settings.TrailThickness;

        chkSmooth.Checked = _settings.Smooth;
        numFps.Value = Math.Clamp(_settings.TargetFps, (int)numFps.Minimum, (int)numFps.Maximum);

        numTrail.Enabled = chkTrails.Checked;
    }

    private void SaveAndClose()
    {
        _settings.StarCount = (int)numStars.Value;
        _settings.WarpSpeed = (float)numSpeed.Value;
        _settings.FieldOfView = (float)numFov.Value;
        _settings.Spread = (float)numSpread.Value;
        _settings.MaxDepth = (float)numDepth.Value;

        _settings.DrawTrails = chkTrails.Checked;
        _settings.TrailThickness = (float)numTrail.Value;

        _settings.Smooth = chkSmooth.Checked;
        _settings.TargetFps = (int)numFps.Value;

        _settings.Save();
        Close();
    }

    private static NumericUpDown Num(decimal min, decimal max, decimal value, int decimalPlaces = 0, decimal increment = 1m)
    {
        var v = Math.Clamp(value, min, max);

        return new NumericUpDown
        {
            Minimum = min,
            Maximum = max,
            Value = v,
            DecimalPlaces = decimalPlaces,
            Increment = increment,
            Dock = DockStyle.Fill
        };
    }
}

NativeMethods.cs(プレビュー埋め込み用P/Invoke)

ここまでしてくれるのか。

using System.Runtime.InteropServices;

namespace StarfieldScreensaver;

internal static class NativeMethods
{
    public const int GWL_STYLE = -16;

    public const int WS_CHILD = 0x40000000;
    public const int WS_VISIBLE = 0x10000000;

    [StructLayout(LayoutKind.Sequential)]
    public struct RECT
    {
        public int Left, Top, Right, Bottom;
    }

    [DllImport("user32.dll", SetLastError = true)]
    public static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);

    [DllImport("user32.dll", SetLastError = true)]
    public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);

    [DllImport("user32.dll", SetLastError = true)]
    public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint);

    [DllImport("user32.dll", SetLastError = true)]
    public static extern int GetWindowLong(IntPtr hWnd, int nIndex);

    [DllImport("user32.dll", SetLastError = true)]
    public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
}

ビルドと .scr 化

  1. フォルダでビルド
    dotnet build -c Release コマンドで実行しないとは思いますが
    
  2. 出力(例)
    bin\Release\net8.0-windows\StarfieldScreensaver.exe
  3. StarfieldScreensaver.scr にリネーム
    その .scr を右クリック → インストール(または「個人設定 > ロック画面 > スクリーンセーバー」から選択)

実際に使ってみる

.scr 化したスクリーンセーバーをインストールし、設定を開く。
コピペしただけとは思えない作った感がなんとなく込み上げてくる。

image.png

設定画面

すごい、ちゃんと設定ボタンで設定画面が開く。GPT4の頃には無かった提案レベルに驚く。
正直、ここまで細かな設定を求めてなかったがGPT4と5.2 Thinkingの圧倒的提案の差について考えさせられる。

image.png

実際にできたスクリーンセーバー

本家のStarfield Simulationにも劣らぬ完成度高めのものが出来た。

55.gif

感想

GPT4のときは「とりあえず動かす」までは辿り着けても、スクリーンセーバーとして“Windowsの作法に沿わせる” 所までは提案してくれませんでした。
しかし GPT 5.2 Thinking では、作りたい内容の解像度が最初から高く、提案の質とレベルが明らかに違うと感じました。

雑な問いでも正解に近づけてくれる

GPT4時代は 「作り込むなら、前提知識をあらかじめセットした上で、製作者側がプロンプトで頑張る」 という感覚でした。
一方で今回は、前提知識を丁寧に渡さなくても、こちらの意図を拾って “完成形に近い方向”へ寄せてくれる

「C#で」「WinFormsで」「スクリーンセーバーの引数はこうで」みたいな、地味だけど重要な前提を省略したのに、ちゃんと成立しており、この時点で、去年までの “プロンプト筋トレ” が要らなくなってきている気配を感じます。

※ 前回のURLの記事を渡している事も影響しているかと思いますが。

機能の提案

GPT 5.2 Thinkingでは、“動くデモ”じゃなくて“スクリーンセーバーとして必要な機能” を最初から提案してくれています。

  • /p <HWND> のプレビュー埋め込み(SetParent + 子ウィンドウスタイル)
  • ApplicationContext でのマルチモニタ管理
  • /c の設定画面を WinForms で一枚完結
  • 設定を %LOCALAPPDATA% に JSON 保存

など、「スクリーンセーバーとはこういうもの」という知識がなくても考えてくれている。
前回は都度、プログラムを修正していましたが、今回は “直して完成させる”という工程がかなり減りました

スクリーンセーバーの提案

また、特に衝撃を受けたのは今回作りたかった Starfieldっぽさのコア について、どう作れば実現できるかを最初に言語化してくれた点です。
本来であれば、

  • 星を N個のオブジェクトとして保持(x, y, z を持つ)
  • 毎フレーム z を減らして(=カメラが前進)手前に来る
  • z が手前を通過した星は 奥へ再配置
  • 3D→2D投影で描画(z が小さいほど外側に広がり、サイズも大きく)

といった 具体的な指示をプロンプトに書かないといけないところまで、GPT 5.2 Thinkingは提案してくれています。

さいごに

今回感じた差は、生成されるコードの巧さ以上に、「仕様をどう理解して、どの粒度で提案してくるか」 でした。

GPT4は、こちらが要件を細かく言語化できたときに強く、指示した範囲を中心に実装や提案を積み上げていく印象があります。
そのため、要件が曖昧だったり前提が省略されていると、提案は安全側(一般的・抽象的)になりやすく、最終的には「どこまでを作るか」「何が抜けているか」をこちらが補完していく形になりがちでした。

一方でGPT 5.2 Thinkingは、曖昧な入力でも意図を推定して、 “完成形として必要な要素” を逆算して埋める方向に動きます。
「何を作るか」だけでなく、「それが成立するために最低限何が必要か」を先に整理してくれるため、こちらが明示していない前提や境界条件まで含めて提案が出てくることが多い。結果として、作業の中心が「抜けを埋める」から「提案された設計を選ぶ・磨く」に移る感覚があります。

提案の粒度も変わりました。
GPT4が「実装の断片」や「手順の提示」に寄りやすいのに対して、5.2 Thinkingは “要件 → 構成 → 具体実装” を一まとまりで出してくることが多く、提案が“部品”ではなく“設計案”として提示してくれます。
そのぶん、こちらの要求が雑でも前に進みやすく、何を決めれば良いか(選択肢とトレードオフ)が見える状態にしてくれるのが大きな違いだと感じました。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?