📄

将棋アプリを作りたい #8 - 駒動作のベクトル定義と移動の制約

に公開

前回までの進捗

将棋アプリを作りたい #7 の時点では、盤の整合性を保つバリデーションだけを書きました。

今回は、駒固有の動きを制御するための制約をコードで表現しました。

改修後の `PieceKind`
// 成っていない駒
export const NormalPieceKindSchema = z.enum([
  "King", "Gold", "Silver", "Knight", "Lance",
  "Bishop", "Rook",
  "Pawn"
]);
export type NormalPieceKind = z.infer<typeof NormalPieceKindSchema>;



// 成れる駒・成れない駒の区別
export const NoPromotablePieceKindSchema = NormalPieceKindSchema.extract([
  "King", "Gold"
]);
export type NoPromotablePieceKind = z.infer<typeof NoPromotablePieceKindSchema>;

export const PromotablePieceKindSchema = NormalPieceKindSchema.exclude([
  "King", "Gold"
]);
export type PromotablePieceKind = z.infer<typeof PromotablePieceKindSchema>;


// 成った駒
export const PromotedPieceKindSchema = z.enum([
  "P_Silver", "P_Knight", "P_Lance",
  "P_Bishop", "P_Rook",
  "P_Pawn"
]);
export type PromotedPieceKind = z.infer<typeof PromotedPieceKindSchema>;


// 駒全種
export const PieceKindSchema = NormalPieceKindSchema.or(PromotedPieceKindSchema);
export type PieceKind = z.infer<typeof PieceKindSchema>;

駒の動き

https://www.shogi.or.jp/knowledge/shogi/03.html

駒の種類分けはいろいろな解釈ができますが、今回は以下の考えで分けます。

動き
1マス系 玉・金・銀・歩
無限マス系 飛・角
特殊

桂馬は、「唯一駒を飛び越えることが出来る」と言われています。しかし、後述するように、実装ロジックではこれを気にする必要はありません。

駒のベクトル定義

もっとも簡単な「歩」はこう定義されます。

{ x方向に0, y方向に1 } を一回分のみ進む

const vectors: PieceVectors = [
  {
    dx: 0,
    dy: 1,
    infinity: false
  }
];

export const pawnMotion: PieceMotion = {
  vectors
}


「飛」のような無限マス系は以下のように定義します。
歩とは違い、 infinity: true と定義します。


const vectors: PieceVectors = [
  {
    dx: 0,
    dy: 1,
    infinity: true
  },
  {
    dx: 1,
    dy: 0,
    infinity: true
  },
  {
    dx: 0,
    dy: -1,
    infinity: true
  },
  {
    dx: -1,
    dy: 0,
    infinity: true
  }
];


export const rookMotion: PieceMotion = {
  vectors
}

画像のように2マス以上であっても進めることを定義しています。

ベクトルに従わない動作を弾く

ベクトルだけ決めてもバリデーションを組んでいない状態では何も起こりません。

今必要なのは以下のバリデーションです。

  • 駒のベクトルに従わない動きをしていないか
  • 他の駒を飛び越えていないか

さて、バリデーションをするためには、駒にmotionを持たせておきたいです。

export class ShogiPiece {
  public readonly motion: PieceMotion;

  constructor(...) {
    ...
    this.motion = motionMap[kind];
  }
  ...
}

motionMapPieceKindmotion を紐付けるためのmapです。
駒に対応するmotionを定義しています。

motionMap
import type { PieceMotion } from "../../types/algebraic.types.js";
import type { PieceKind } from "../../types/piece.types.js";
import { bishopMotion, goldMotion, kingMotion, knightMotion, lanceMotion, pawnMotion, rookMotion, silverMotion } from "./normal/index.js";
import { p_BishopMotion, p_KnightMotion, p_LanceMotion, p_PawnMotion, p_RookMotion, p_SilverMotion } from "./promoted/index.js";


export const motionMap: Record<PieceKind, PieceMotion> = {
  King: kingMotion, Gold: goldMotion, Silver: silverMotion, Knight: knightMotion, Lance: lanceMotion,
  Bishop: bishopMotion, Rook: rookMotion,
  Pawn: pawnMotion,

  P_Silver: p_SilverMotion, P_Knight: p_KnightMotion, P_Lance: p_LanceMotion,
  P_Bishop: p_BishopMotion, P_Rook: p_RookMotion,
  P_Pawn: p_PawnMotion
}

これを元にバリデーションを書いていきます。

ベクトル上の座標かを確かめる

まずは、「駒の追い越しの禁止」は後回しにして、単にベクトル上にあるかだけを見ます。
これ単体では追い越しが可能です。

const assertMotionVector = (board: Board, current: Position, next: Position): void => {
  const piece = board.squares[current.y]![current.x];
  
  if (!piece) throw new MovementError("MOVE_UNDEFINED_PIECE");

  const vectors = piece.motion.vectors;
  const direction = piece.side === "Sente" ? -1 : 1;

  const isValid = vectors.some(vector => {
    // 先手・後手でベクトルの向きが変わる
    // dx に関しては、駒の動作が線対称であるためあってもなくても成り立つ
    const dx = vector.dx * (direction * -1);
    const dy = vector.dy * direction;

    let x = current.x + dx;
    let y = current.y + dy;

    if (vector.infinity) {
      while (positionValidator.isInBoard(x, y)) {
        // 移動先の座標がvectorから計算された場所に合っていれば true
        if (next.x === x && next.y === y) return true;

        x += dx;
        y += dy;
      }
    } else {
      if (next.x === x && next.y === y) return true;
    }
    return false;
  });

  if (!isValid) throw new PieceError("INVALID_MOTION_VECTOR");
}

他の駒を追い越していないかを確認

駒の追い越しはmotionに起因するため、同じレイヤーでバリデーションをします。

const violatesLeapRestriction = (board: Board, current: Position, next: Position): void => {
  const piece = board.squares[current.y]![current.x];

  if (!piece) throw new MovementError("MOVE_UNDEFINED_PIECE");

  const vectors = piece.motion.vectors;
  const direction = piece.side === "Sente" ? -1 : 1;

  for (const vector of vectors) {
    const dx = vector.dx * (direction * -1);
    const dy = vector.dy * direction;

    let x = current.x + dx;
    let y = current.y + dy;
    let collided = false;

    if (vector.infinity) {
      while (positionValidator.isInBoard(x, y)) {
        // 目的の地点についたときに、過去に他の駒と衝突しているということは追い越しが行われている。
        if (next.x === x && next.y === y) {
          if (collided) throw new PieceError("LEAP_RESTRICTION");
          return;
        }

        if (board.squares[y]![x]) {
          collided = true;
        }

        x += dx;
        y += dy;
      }
    };
  };
}

ここでは、forEatchではなくfor (const vector of vectors) { ... }を使います。

  • forEach全ての要素を捜索して文の結果が決定する
  • for...of合致する要素があった時に文の結果を決定することが出来る
for (const vector of vectors) {
  ...
  
  if (next.x === x && next.y === y) {
    if (collided) throw new PieceError("LEAP_RESTRICTION");
    return;
  }
}

このロジックでは、「目的の地点に到達するvector」が文を決定するため、forEatchと違って後続要素の処理を行わう必要がなくなります。

例えば、飛車を右側に進めたとすれば、
「上: 何もなし」、「右: ヒット → return or throw」と進み、下と左は巡回しません。
しかし、forEachにするとすべて巡回するため無駄が生まれます。


桂馬のジャンプは特例ではなかった

人間的な感覚では「桂馬だけは他の駒を飛び越せる」となりますが、
このロジックでは「過去に駒と衝突した状態で移動を続けられない」ことを飛び越すの定義としています。

ロジックとしては点でしか見ていないため、飛び越えるという人間的な感覚を適用しなくて済みます。

人間的な感覚 (衝突あり) ロジック上での仕組み(衝突なし)

「移動途中に他の駒に衝突してはいけない」というルールは、桂馬でも同じことが言えていたのです。

一方、飛車などの駒は以下のようになります。

最初に、移動先 { x: 7, y: 5 } に移動可能なベクトルを特定します。
そのベクトル { dx: 0, dy: 1, infinity: true } の移動において、移動途中に「他の駒に当たっていた」と判定され、これは反則になります。


さて、2つのバリデーションが完成したので、1つのバリデーションとして組み立てます。

export const pieceMotionValidator = (board: Board, current: Position, next: Position) => {
  assertMotionVector(board, current, next);
  violatesLeapRestriction(board, current, next);
}

既存のバリデーションに組み込んで利用可能です。


export const moveValidator = {
  canMove: (board: Board, current: Position, next: Position) => {
    positionValidator.assertInBoard(current.x, current.y);
    positionValidator.assertInBoard(next.x, next.y);

    // ここに挿し込み
    pieceMotionValidator(board, current, next);
    ...
}

まとめ

  • コーディングする際は、x軸は右が正・y軸は下が正とするのがスタンダード(Google調べ)
  • 駒のベクトル反転問題は、軸方向の扱いにおいて何を取るか分かりにくくなりがちで、一貫した意図を残しておく必要がある
  • 今回はConfigでの人間フレンドリーな設定を優先するために座標方向を崩した
  • 意図がなければすべて軸の定義に合わせた方が事故が起きにくい

テスト

テスト結果

 DEV  v4.1.5 C:/Users/Fujishu/Desktop/Document/portfolios/Game/undo-shogi

 packages/core/entities/Piece/__test__/Piece.test.ts (3 tests) 5ms
 pieceMotionVector (3)
 他の駒を追い越せない 2ms
 定義されていないベクトル移動はできない 0ms
 ベクトル定義に沿った移動が可能である 1ms

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  18:29:29
   Duration  697ms (transform 166ms, setup 0ms, import 380ms, tests 5ms, environment 0ms)

テストコード

describe("pieceMotionVector", () => {
  it("他の駒を追い越せない", () => {
    const board = new Board(hirateSquares);

    const invalidPosList: Position[] = [
      { x: 7, y: 4 },
      { x: 0, y: 7 }
    ];

    invalidPosList.forEach(pos => {
      expect(
        () => pieceMotionValidator(board, { x: 7, y: 7 }, pos)
      ).toThrow(PieceError);
    });
  });

  it("定義されていないベクトル移動はできない", () => {
    const board = new Board(hirateSquares);

    const invalidPosList: Position[] = [
      { x: 1, y: 1 },
      { x: 5, y: 4 },
      { x: 3, y: 7 }
    ];

    invalidPosList.forEach(pos => {
      expect(
        () => pieceMotionValidator(board, { x: 1, y: 8 }, pos)
      ).toThrow(PieceError);
    });
  });

  it("ベクトル定義に沿った移動が可能である", () => {
    const board = new Board(hirateSquares);

    expect(
      () => pieceMotionValidator(board, { x: 7, y: 7 }, { x: 3, y: 7 })
    ).not.toThrow();

    expect(
      () => pieceMotionValidator(board, { x: 4, y: 8 }, { x: 5, y: 7 })
    ).not.toThrow();
  });
});

Discussion