砂漠の旅人(たびと)

UNIX / MS-DOS 時代から電脳砂漠を旅しています

【座標移動】キーボード入力を判定するXY座標の移動計算で、if や switch を使わないロジックは本当に邪道なのか?

こんにちは、たびとです。

昔、ぷよぷよの開発元がコンパイルだった時代、 学生プログラマのたびとは事務系開発のアルバイトをしていました。 当時、オフコン(たしかNEC製)と呼ばれる結構大きな筐体のコンピュータで、 BASIC 言語を使って事務系のプログラムを開発していました。

この開発チームには、同じアルバイト仲間のNくんと言う、 ひとつ年下のとても優秀な人がいて、あるとき、こんな会話がありました。

  • Nくん:「たびとさん、ゲームしたくないですか?」
  • たびと:「やりたいけど、ここにゲームなんてないよね。」
  • Nくん:「大丈夫です。僕が作りますよ。」

そして、Nくんはテトリスもどきを1時間ぐらいで作り上げてしまいました。

彼は仲間と共にゲームを作っているらしく、コードをみせてもらいましたが、 たびとには理解の及ばない世界で、Nくんとの力の差に愕然としたことを今でも覚えています。

特に記憶に残ったのが、テトリスのブロック移動はキーボードの入力を判定するので、 if 文で入力されたキーの値を判定し、ブロックのXY座標を計算すると思っていました。 しかし、Nくんの座標計算は、if 文も使わずに、たった2行で実装されていたのです。

この記事の対象者

  • キーボードから入力された値を判定するのに、if や switch 以外を思いつかない方
  • ゲームやエディタなど、キーボードからの入力値を利用して XY座標を移動させる方法を知りたい方
  • 上限や下限の判定に if や switch などを使わずに計算式で判定する方法を知りたい方

あれから数年後

数年後、たびとも就職が決まり、 システム開発に明け暮れた日々を過ごしていました。 Nくんの作ったテトリスも記憶の彼方へ消え去ろうとしていましたが、 ふとした瞬間に座標計算で悩んでいた記憶が蘇ってきました。

ブロックを移動させる座標計算

Nくんのブロック移動の座標計算は、座標計算の基本を 忠実にプログラムで再現したモノでした。

  • x = x + Δx
  • y = y + Δy

画面左上が原点とすると、それぞれの移動式は以下のようになります。

  • 左へ移動:左移動キー判定 × -1
  • 右へ移動:右移動キー判定 × 1
  • 上へ移動:上移動キー判定 × -1
  • 下へ移動:下移動キー判定 × 1

これをXY軸で整理すると、以下のようになります。

  • X座標:x = x + (左移動キー判定 × -1) + (右移動キー判定 × 1)
  • Y座標:y = y + (上移動キー判定 × -1) + (下移動キー判定 × 1)

それぞれのキー判定は真偽のブール値になり、キーがヒットすれば移動量がセットされ、 キーがヒットしなければ偽なのでゼロがセットされます。

これが、Nくんが2行で実現したブロック移動の座標計算式です。

キー判定の実装

キー入力の判定を C# で実装してみます。

キーボードからの入力値を System.Windows.Input.Key k とし、 シンプルにするため、入力は矢印キーのみを押したかどうかを判定します。

  • 左へ移動:Convert.ToInt32(k == Key.Left) × -1
  • 右へ移動:Convert.ToInt32(k == Key.Right) × 1
  • 上へ移動:Convert.ToInt32(k == Key.Up) × -1
  • 下へ移動:Convert.ToInt32(k == Key.Down) × 1

Convert.ToInt32 は条件式が真の場合は 1、偽の場合は 0 になります。

ただし、Basic は真の場合、全てのビットが立つので -1 になります。

これをXY座標で整理すると、以下のようになります。

  • x = x + Convert.ToInt32(k == Key.Left) × -1 + Convert.ToInt32(k == Key.Right) × 1
  • y = y + Convert.ToInt32(k == Key.Up) × -1 + Convert.ToInt32(k == Key.Down) × 1

周囲の反応

かつて、Basic や C/C++ を使って、この座標計算を応用したソースコードを 他の開発メンバーに見せたところ、トリッキーだとか邪道と言った 否定的なコメントが大多数でした。

当時は、ここまで丁寧に解説した訳でもないので、 当初のたびとと同じく、理解の範疇を超えたのでしょう。

応用編

応用編として実際に動作できるサンプルを C# で作ってみます。 ここでも条件判定に if や switch 文を使わずに作っていきます。

プレイヤーを移動させるサンプルの画像
プレイヤーを移動させるサンプル

移動ルール

移動に関しては、以下のルールとします。

  • テンキー(数字キー)または矢印キーを押すとプレイヤーが動く。
  • プレイヤーは画面の端まで行くと、反対側の端から出現する。
  • Enter キーまたは 'Q' キーを押すと速度を1段加速する。
  • Back キーまたは 'S' キーを押すと速度を1段減速する。

右端に移動を続けると、左端から現れる画像
右端に移動を続けると、左端から現れる

ソースコードの作成

Visual Studio 2022 の新しいプロジェクトで、C# / Windows / デスクトップ、WPF アプリケーションを選択し、 プロジェクト名を「MovePlayer」と入力し、.NET 6 を選択します。

MainWindow.xamlソースコードは以下と差し替えます。

<Window x:Class="MovePlayer.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:MovePlayer"
        mc:Ignorable="d"
        Title="Move Player" Height="300" Width="300">
    <DockPanel x:Name="dockPanelArea">
        <Canvas x:Name="canvasArea">
            <Image x:Name="imageExterior" Source=".\Resources\tabito.png" Height="50" Width="50" Visibility="Collapsed" />
        </Canvas>
        <StatusBar x:Name="statusBar" VerticalAlignment="Bottom" DockPanel.Dock="Bottom">
            <StatusBarItem x:Name="statusBarItem" Content="" />
        </StatusBar>
    </DockPanel>
</Window>
  • プレイヤーは、Image とし、リソースに追加した好きな画像を表示します。画像が表示されないときは、画像ファイルのプロパティでビルドアクションが「リソース」になっていることを確認してください。
  • プレイヤーが移動できる領域は Canvas 内とします。Canvas の範囲を越えても見えなくなるだけで、指定の座標には存在します。

MainWindow.xaml.cs のソースコートを以下と差し替えます。

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace MovePlayer
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            // 初期設定
            var player = new Player()
            {
                Size = new Size(imageExterior.Width, imageExterior.Height),
                Image = imageExterior,
            };

            // 最初の表示
            this.ContentRendered += (s, e) =>
            {
                var rnd = new Random().NextDouble();
                var x = (dockPanelArea.ActualWidth - player.Size.Width) * rnd;
                var y = (canvasArea.ActualHeight - player.Size.Height) * rnd;
                player.Point = new Point(x, y);
                MoveTo(player, Key.Escape);
                imageExterior.Visibility = Visibility.Visible;
            };

            // サイズ変更後
            this.SizeChanged += (s, e) => player.Area = new Size(dockPanelArea.ActualWidth, canvasArea.ActualHeight);

            // キー判定
            this.KeyDown += (s, e) => MoveTo(player, e.Key);
        }

        private void MoveTo(Player player, Key key)
        {
            var old = player.Point;
            player.Move(key);
            statusBarItem.Content = $"Spd:{player.Speed}, Key:{key}, (x,y)=({old.X:0.#},{old.Y:0.#})=>({player.Point.X:0.#},{player.Point.Y:0.#})";
        }

        public class Player
        {
            public double Speed { get; set; } = 1.0;        // 移動速度
            public Point Point { get; set; }                // 現在位置
            public Size Size { get; set; }                  // プレイヤーサイズ
            public Size Area { get; set; }                  // 移動領域
            public Image Image { get; set; } = new Image(); // プレイヤー画像

            public void Move(Key k)
            {
                // キー判定
                var isQuick = k == Key.Enter || k == Key.Q;
                var isSlow = k == Key.Back || k == Key.S;
                var isLeft = k == Key.Left || k == Key.NumPad1 || k == Key.NumPad4 || k == Key.NumPad7 || k == Key.D1 || k == Key.D4 || k == Key.D7;
                var isRight = k == Key.Right || k == Key.NumPad3 || k == Key.NumPad6 || k == Key.NumPad9 || k == Key.D3 || k == Key.D6 || k == Key.D9;
                var isUp = k == Key.Up || k == Key.NumPad7 || k == Key.NumPad8 || k == Key.NumPad9 || k == Key.D7 || k == Key.D8 || k == Key.D9;
                var isDown = k == Key.Down || k == Key.NumPad1 || k == Key.NumPad2 || k == Key.NumPad3 || k == Key.D1 || k == Key.D2 || k == Key.D3;

                // 移動速度
                Speed += -Convert.ToInt32(0 < Speed) * Convert.ToInt32(isSlow) + Convert.ToInt32(Speed < 10) * Convert.ToInt32(isQuick);

                // 移動式
                var x = Point.X + Convert.ToInt32(isLeft) * -Speed + Convert.ToInt32(isRight) * Speed;
                var y = Point.Y + Convert.ToInt32(isUp) * -Speed + Convert.ToInt32(isDown) * Speed;

                // Y座標境界判定
                var h1 = Convert.ToInt32(-Size.Height <= y && y <= Area.Height);
                var h2 = Convert.ToInt32(y < -Size.Height);
                var h3 = Convert.ToInt32(Area.Height < y);

                // X座標境界判定
                var w1 = Convert.ToInt32(-Size.Width <= x && x <= Area.Width);
                var w2 = Convert.ToInt32(x < -Size.Width);
                var w3 = Convert.ToInt32(Area.Width < x);

                // 座標特定
                x = w1 * (x % (Area.Width + 1)) + w2 * Area.Width + w3 * -Size.Width;
                y = h1 * (y % (Area.Height + 1)) + h2 * Area.Height + h3 * -Size.Height;

                // 座標移動
                Canvas.SetLeft(Image, x);
                Canvas.SetTop(Image, y);
                Point = new Point(x, y);
            }
        }
    }
}

MainWindow ポイント解説

  • 初期設定でプレイヤーを作成し、プレイヤーのサイズと画像を設定します。
  • 最初の表示は、出現位置をランダムに設定し、ダミーに ESC キーを与え、プレイヤーを表示します。
  • サイズ変更後は、ウィンドウサイズを変更したときに、移動領域を再設定しています。
  • キー判定は、入力されたキーを元に座標を計算し、プレイヤーを動かします。

Player クラスのポイント解説

  • Speed は移動速度です。0 (停止) から 10 (高速) までの移動速度を設定します。
  • キー判定は、それぞれの指定したキーが押されると 真(true) になります。それ以外は偽(false)になります。

移動速度は、XY座標と同様に「減速と加速」を足して作ります。

  • 減速:-1 ×(スピードがゼロ以上)×(減速キー判定)
  • 加速:+1 ×(スピードが 10 未満)×(加速キー判定)
  • 速度:Speed += 減速 + 加速

移動式は、前半の説明にスピードを掛けて作ります。

  • X座標:x = x +(左移動キー判定 × -速度)+(右移動キー判定 × +速度)
  • Y座標:y = y +(上移動キー判定 × -速度)+(下移動キー判定 × +速度)

次に端まで行った時の判定式を作ります。 このとき、プレイヤーのサイズ分を考慮します。

  • X範囲内:-プレイヤーのサイズ分以上、かつ、移動領域以内
  • X左端越:-プレイヤーのサイズを超えた
  • X右端越:移動領域を超えた

Y軸も同様に作ります。

最後に領域の境界を考慮して、座標を特定します。

  • X座標:範囲内 × (x % (領域横幅 + 1)) + 左端越 × 領域横幅 + 右端越 × -プレイヤー横幅
  • Y座標:範囲内 × (y % (領域縦幅 + 1)) + 上端越 × 領域縦幅 + 下端越 × -プレイヤー縦幅

% 演算子は余りを計算するもので、「 a % 10 」とした場合、答えは 「 0 から 9 まで 」となります。 このため、上記では幅に +1 を加算しています。

まとめ

今回は、if や switch 文を使わずに、ブール式を用いた条件判定を行いました。 これにより、方式設計通りにプログラムを記述できるようになると思います。

ただし、応用編は少しやり過ぎなので、仕事で使う場合は注意が必要です。 設計書で丁寧に説明しないと、そもそも皆に理解されないでしょうし、 皆が理解できないとメンテナンス性が下がるため、通常は許可されないでしょう。

さて、冒頭のアルバイト時代の話題に戻りますが、 アルバイト先の社長はコンパイル社の社長と知り合いで、 たびともアルバイト先の社長と一緒にコンパイル社を 見学したことがあります。 Nくんといい、ゲーム業界が身近にあった古き良き時代でした。

では、皆さん、よい旅を。