ゲーム作る.Hex座標系.log

六角形のセルを使った座標系を Swift で実装する。

ちなみにSwiftで実装する意味は実のところない。普通にやるならC#とか使えばいいし、C#とか使うならライブラリがありそう。

要件

とりあえず脳内で要件をまとめる。

Hex座標系を表現する方法はいろいろあるけれど、座標表現のために最低限必要な情報は原則的に2つの数値である。これを (q, r) で表す。(別に x, y でもいい)

これを参考にする https://www.redblobgames.com/grids/hexagons/ こういうのは先人がすでにいろいろ考えたことを踏襲するのが良い。

Hex座標系にほしい機能は「移動経路を簡単に計算できる」「Cell と Cell の間になにかを仕込める(罠とかイベントとか)」「ある座標から他の座標に働きかける時に、Hex座標系に当たり判定を委譲(Delegate)できる」といった感じだと思う。もうちょっと低レイヤだと、「座標の床条件を登録できる」「座標に存在するオブジェクトを記録できる」とかになる。

とりあえず、ある座標に存在する相互に重なることが出来ないオブジェクトをToken、床をField、Tokenを排除しない設置物をOrnamentと決める。そして、個別の座標を占める空間そのものをCellと呼び、Cellの集合をHexSpaceとしておく。

Hex座標系には向きがある。辺を上にしたものと、頂点を上にしたものだ。まあどちらでも良いので、今回は頂点を上にする。例えばスマートフォンでゲームを作る場合、横持ちにすることが多いと思う。横方向にまっすぐいどうできるほうが見た目が良いから頂点を上にする……とか、そういうフィーリング上の良し悪しはある。とはいえ座標系そのものは回転可能なので、セルを表現する画像チップ以外はどうとでもなるんじゃないだろうか。プレイヤーの画面の持ち方によって回転させても良いかもしれないし、そもそもプレイヤーが自由に回転させられても良いと思う。好きにしてくれ。

君がやりたいこと、それが要件の全てだ。

あと、イメージとして、マップの大きさはせいぜい「50×50」とかその程度だ。したがってメモリ効率とか計算効率とかあんまり重視しない。(経路計算とかだと効率ちゃんとしておかないと辛くなったりするかもしれない)

仕様

仕様を検討することと実装を行うことは不可分である。仕様策定の手順と実装の手順を分離するためには、試作品を作らなければならない。
もしハードウェアを扱うプロジェクトなら試作品をどう安価につくるのか、そして試作品でテストしたいポイントをなるべく明確にすることが必要になるのだろう。ハードやんないからわかんないけど。
今作るのはソフトウェア上の課題に終始しているので、試作品を作りながら仕様を固めつつ、完成した「うまく動く試作品」をそのまま最終的な実装であることにする。趣味だし適当でいいんだよ。

いつだって仕様は前提じゃない、結果だ。バグは夜明けとともに仕様に変わる。

まあそうはいっても、要件を妄想したことによって大雑把なイメージが湧いてくる部分もある。

まず、Hex座標系で表現されるHex座標空間はCellの集合である。Hex座標空間の利用モチベーションは2種類に分けられる。「Hex座標空間の作成」と「Hex座標空間の操作・測定」だ。
これは同時に求められない要件でもある。
そこで、Hex座標空間の作成部分はHexSpaceFactoryに分離し、HaxSpaceSystem本体が操作・測定のAPIをクライアントコードに提供することにしよう。

ところで表現系はどうするの?

Hexマップなので本当はグラフィックでいい感じにしたほうがいいのだけど、そんなの実装するのめんどくさいのでテキスト上にプロットする。(参考:ローグ
ちなみにテキストは横方向に辺が接しているので、頂点を上に配置するHex座標系だと出力が楽かもしれない。​

HexSpaceFactoryを適当に作る

Hex空間を構築するコードはレシピみたいなもので、これは要するに「どういう風にレシピを書けばわかりやすいか」みたいな感じでもある。とりあえずペイントみたいに、「ここからここまで、こういうセルをおいてね」とできるようにしてみよう。

class HexSpaceFactory {
  typealias Builder = (inout HexCell) -> Void

  func addLine(
    start: HexCoordinate,
    direction: Direction,
    length: Int,
    builder: Builder? = nil
  )

  func addTriangle(
    point: HexCoordinate,
    direction: Direction,
    length: Int,
    rotation: Rotation = .clockwise,
    builder: Builder? = nil
  )

  func addHexagram(
    center: HexCoordinate,
    range: Int,
    builder: Builder? = nil
  )
}

addLineはセルを直線上に配置する。開始点と方向、長さがあれば良い。

addTriangleはセルを正三角形に配置する。頂点のうち一つと、辺の長さと、辺を伸ばす方向と、最後の頂点の場所がどっちがわにあるのかが分かれば良い。最初の頂点から指定された方向に指定された長さの辺を伸ばし、そこから時計回り(or反時計回り)に60度移動した点を最後の頂点として、三角形を一意に決められる。↑のコードでは Rotation で回転方向を設定できるようにしているけれど、実際にはどっちかに固定しておいて良いと思う。

addHexagram は大きな六角形を(つまり擬似的には円状のCellの集合を)Hex空間に追加する。中心と半径があれば良い。

実際のところ、addTriangleは「長さを1減らしながら1CellずつずれるaddLineのループ」だし、addHexagramは「方向の違う6回分のaddTriangle」なので、addLineから実装するのが良さそうである。処理速度はとりあえず気にしない。

Builderはセルの生成ロジックを外部から注入するためのもの。(inout HexCell) -> Void より () -> HexCell のほうが良いかもしれない。難しいね。

まずHex座標系上の方向を表現するHexDirectionと、回転方向を表現するRotationを定義する。

enum HexDirection: Int, Equatable {
   case upRight = 0
   case right = 1
   case downRight = 2
   case downLeft = 3
   case left = 4
   case upLeft = 5
   
   var vector: HexCoordinate {
       switch self {
       case .upRight:
           return HexCoordinate(q: 1, r: -1)
       case .right:
           return HexCoordinate(q: 1, r: 0)
       case .downRight:
           return HexCoordinate(q: 0, r: 1)
       case .downLeft:
           return HexCoordinate(q: -1, r: 1)
       case .left:
           return HexCoordinate(q: -1, r: 0)
       case .upLeft:
           return HexCoordinate(q: 0, r: -1)
       }
   }
   
   func rotate(_ rotation: Rotation) -> HexDirection {
       switch rotation {
       case .clockwise:
           return HexDirection(rawValue: (rawValue + 1) % 6)!
       case .anticlockwise:
           return HexDirection(rawValue: (rawValue + 5) % 6)!
       }
   }
}

enum Rotation {
   case clockwise
   case anticlockwise
}

ここまで見て思ったけど、HexDirectionって原点から1つずれた座標のHexCoordinateと同値なのでうまいこと整理できそうな気がする。

ちなみにHexCoordinateも定義している。

struct HexCoordinate: Equatable {
   var q: Int
   var r: Int
   
   static let zero: HexCoordinate = HexCoordinate(q: 0, r: 0)
   
   static func +(lhs: HexCoordinate, rhs: HexCoordinate) -> HexCoordinate {
       return HexCoordinate(q: lhs.q + rhs.q, r: lhs.r + rhs.r)
   }
   
   static func *(lhs: HexCoordinate, rhs: Int) -> HexCoordinate {
       return HexCoordinate(q: lhs.q * rhs, r: lhs.r * rhs)
   }
   
   static func ==(lhs: HexCoordinate, rhs: HexCoordinate) -> Bool {
       return lhs.q == rhs.q && lhs.r == rhs.r
   }
}

HexCoodinate+HexCoordinateの演算は注意が必要で、単純に足し引きするので原点からのズレが影響しちゃう。本当はHexVectorを定義したほうが良いと思う。まあ後でリファクタリングしよう。

そして、HexSpaceFactoryがこれ。

class HexSpaceFactory {
   
   typealias Builder = (HexCell) -> Void
   
   var builders: Array<(HexCoordinate, Builder?)> = []
   
   func set(at coordinate: HexCoordinate, builder: Builder? = nil) {
       if let index = builders.firstIndex(where: { $0.0.q == coordinate.q && $0.0.r == coordinate.r }) {
           builders[index] = (coordinate, builder) // [!] Set new builder
       } else {
           builders.append((coordinate, builder))
       }
   }
   
   func addLine(start: HexCoordinate, direction: HexDirection, length: Int, builder: Builder? = nil) {
       (0..           self.set(at: start + (direction.vector * $0), modifyHandler: builder)
       }
   }
   
   func addTriangle(point: HexCoordinate, direction: HexDirection, length: Int, rotation: Rotation = .clockwise, builder: Builder? = nil) {
       var l = length
       var s = point
       let ad = direction.rotate(rotation)
       while (l > 0) {
           defer {
               l -= 1
               s = s + ad.vector
           }
           addLine(start: s, direction: direction, length: l, builder: builder)
       }
   }
   
   func addHexagram(center: HexCoordinate, range: Int, builder: Builder? = nil) {
       (0...5).forEach { i in // [!] Each HexDirection values.
           let d = HexDirection(rawValue: i)!
           self.addTriangle(point: center, direction: d, length: range, builder: builder)
       }
   }
   
   func build() -> Array {
       return builders.map { args in
           let (coordinate, builder) = args
           let cell = HexCell(coordinate: coordinate)
           builder?(cell)
           return cell
       }
   }
}

すごい雑……。lとかsとか使ってるのやばいと思う。

テストコードも書いてprintしてみる。

テスト

    func testAddLine() {
       let factory = HexSpaceFactory()
       factory.addLine(start: .zero, direction: .right, length: 10)
       let space = HexSpace(use: factory)
       
       XCTAssertEqual(space.cells.count, 10)
       debugPrint(space)
   }

結果

==========================================
 [  ][  ][  ][  ][  ][  ][  ][  ][  ][  ]
==========================================

テスト

    func testAddTriangle() {
       let factory = HexSpaceFactory()
       factory.addTriangle(point: .zero, direction: .upRight, length: 3)
       let space = HexSpace(use: factory)
       
       XCTAssertEqual(space.cells.count, 6)
       debugPrint(space)
   }

結果

==============
     [  ]
   [  ][  ]
 [  ][  ][  ]
==============

テスト

    func testAddHexagon() {
       let factory = HexSpaceFactory()
       factory.addHexagram(center: HexCoordinate(q: 0, r: 0), range: 3)
       let space = HexSpace(use: factory)
       
       XCTAssertEqual(space.cells.count, 19)
       debugPrint(space)
   }

結果

======================
     [  ][  ][  ]
   [  ][  ][  ][  ]
 [  ][  ][  ][  ][  ]
   [  ][  ][  ][  ]
     [  ][  ][  ]
======================

うまく動いている。addLineは.right方向に伸ばしたので1行で済んでるけど、.upRightとかにするとすごくなる。

========================
                   [  ]
                 [  ]
               [  ]
             [  ]
           [  ]
         [  ]
       [  ]
     [  ]
   [  ]
 [  ]
========================

自由だ。これはHex座標系においては直線である。なんかこういう関数あったよね、なんだっけ……?

もしaddLine、addTriangle、addHexagram以外のツールがほしくなったら、HexSpaceFactoryをいじったり、もし利用側固有のコードなら拡張したりすると良い。これでHexSpaceから「どのように空間が構築されるのか」が分離されたし、テスト用の空間生成コードなども分離できるようになったような気がする。
テストがテスト対象のコードを頼るとテストの前提が多くなって死ぬが、時として必要なものである。テスト内部にテストのためのコードが増えてテストのテストができないよりマシだ。

ちなみにaddHexagramの内部は三角形を6個作っているが、各辺にあたるセルが2重になるし、中心に至っては6重になる。これを吸収しているのがsetメソッドになっている。containsの速度が気になるので、あとでセルを10000個くらい扱うFactoryを定義してテストしてみよう。多分問題が発覚するので、後で直す。

HexSpaceの実装:Token・Ornamentの管理

HexSpaceに求める機能はいくつかある。最も重要なのはTokenとOrnamentの管理だ。

HexSpaceはCellの集合として空間を表現する。Cellが存在する座標には空間があり、Cellが存在しない座標に空間はない(シチュエーションとしては、崖の下とか海とか破壊不能な建造物があるとか普通にマップの端っことか、そういう場所にはCellがないということになる)

「Cellは区切られた1つの空間を表現する」ものであると考えるならば、Cellがその内側にあるTokenやOrnamentを保持するのが良い。
一方で、TokenやOrnamentがHexCoordinateを持つという方法もある。CellはFieldとセットで、もしFieldが不変なら、「Cellは区切られた1つの空間の不変な要素を表現する」ものと解釈したほうが良さそうだ。

Fieldが不変かどうかは、実際に表現したい空間の具体的なイメージを思い浮かべると決められそうである。Hexマップを利用したシミュレーションゲームなどによくあるのは、「マスごとに移動コストが異なる」とか「マスごとに高さが異なる」とかだろうか。あるいは「毒のトゲがある」とか「溶岩や水などの液体のあるマスである」ということも考えられる。しかしこういった要素はOrnamentによっても表現できそうである。

逆にOrnamentで扱いづらいデータはなんだろうか。例えば、Hexで表現されたセルの任意の辺に「壁」があるとか、前述したとおりCellの高さなどは、Ornamentでは扱いづらいだろう。「壁」や「高さ」はTokenの視界に映るものをリストアップするために使うことができるだろうし、当然、経路探索にも重要な要素となる。「壁」や「高さ」を空間的要素として受け入れるかどうかは作りたいゲームの実装によるが、今回はこの二つの要素をFieldで管理することにし、また空間が1度生成されたら、Fieldの情報は永続的に変化しないものとしよう。

「壁」は無限大の高さを持ち、視界を2次元空間的に遮る。

「高さ」は本当は視界を遮るだろうが、どのくらいの「高さ」だとどのくらいの視界を遮るのかを決めるためには、そもそもTokenやOrnamentにも高さが定義されなければならないし、TokenがOrnamentがそのCellのどこにいるのかも検討する必要があるかもしれない。もちろんHex空間は抽象化された空間なので、完全に考慮するのは無理である。考慮するならそもそもHex空間にしないほうがよい。なので、ある程度の抽象化が必要となるだろう。これについては後述しよう。

class HexCell {
   let coordinate: HexCoordinate
   private(set) var field: Field = .none
   private var isFreezed: Bool = false
   
   init(coordinate: HexCoordinate) {
       self.coordinate = coordinate
   }
   
   convenience init(q: Int, r: Int) {
       self.init(coordinate: HexCoordinate(q: q, r: r))
   }
   
   func freeze() {
       isFreezed = true
   }
   
   func setField(_ field: Field) {
       guard isFreezed == false else {
           assertionFailure()
           return
       }
       
       self.field = field
   }
}

こんな感じかな。Cellは永続的な空間情報だけを持ち、TokenやOrnamentは「それが存在する座標」を記録することで管理する。まあ暫定なので、実装していくうちに修正するかもしれない。

さてTokenやOrnamentに直接HexCoordinateをもたせてもいいが、そうするとTokenやOrnamentが自分の絶対座標を知っていることになってしまう。実装の都合はともかく、実際のところ「マップ上にいるキャラクターが自分の絶対座標を知っている」のはあまり直感的ではない。
キャラクターの主観では、自分を原点とした空間が広がっているように見えるだろう。キャラクターのコードを実装する時は、キャラクターの主観で記述できたほうが楽っぽい気がする。(この辺、あんまり経験がないのでわからない)
したがって、TokenやOrnamentにそのままHexCoordinateを保持させるのではなく、HexCoordinateとTokenのペアをHexSpaceに管理させることにしよう。

ところでTokenとOrnamentの違いは「TokenはToken同士、排他的にCellを専有する」「OrnamentはCellを専有しない」という違いしかない。ひとまとめにHexSpaceObjectとしよう。

protocol HexSpaceObject: AnyObject {
   
   // Unique ID on some hex space.
   var hexId: Int { get set }
   
   // The token exclusively holds a cell, another tokens can't pass and stop holded cells.
   var isToken: Bool { get }
}

extension HexSpaceObject {
   var isOrnament: Bool { !isToken }
}​

あとは適当に管理するためのコードを追加する。HexSpaceObjectはかならず参照型なので、updateはいらない。

class HexSpace { // 抜粋
   var placements: Array<HexSpaceObject> = []
   var positions: Array<HexCoordinate> = []
   var nextHexId = 1
}

extension HexSpace {
   
   func place(_ object: HexSpaceObject, at coordinate: HexCoordinate) -> Bool {
       
       guard contains(object) == false else {
           assertionFailure()
           return false
       }
       
       defer { nextHexId += 1 }
       
       assert(object.hexId <= 0)
       
       object.hexId = nextHexId
       objects.append(object)
       
       if checkCellExists(at: coordinate) == false {
           assertionFailure()
           return false
       }
       
       if checkTokenExists(at: coordinate) == true {
           assertionFailure()
           return false
       }
       
       positions[object.hexId] = coordinate
       return true
   }
   
   func remove(_ object: HexSpaceObject) {
       
       guard let index = index(of: object) else {
           assertionFailure()
           return
       }
       
       defer { object.hexId = 0 }
       
       positions.remove(at: object.hexId)
       objects.remove(at: index)
   }
   
   func checkTokenExists(at coordinate: HexCoordinate) -> Bool {
       return objects
           .filter { $0.isToken }
           .map { positions[$0.hexId] }
           .contains(coordinate)
   }
   
   func contains(_ object: HexSpaceObject) -> Bool {
       return objects.contains { $0 === object }
   }
   
   private func index(of object: HexSpaceObject) -> Array<HexSpaceObject>.Index? {
       return objects.firstIndex { $0 === object }
   }
}

本当はplacementsとかの扱いをスレッドセーフにしたほうがいいけど、まあとりあえず後回しってことで。

HexSpaceの実装:Tokenの視界

もうめんどくさい気持ちになってるんだぜ。なんでこんなめんどくさいことを始めてしまったんだ。疲れたのでジェノベーゼパスタでも作ることにする。余談だが、我が家でパスタを茹でる時の水量は蛇口上げて20秒分である。お湯が沸くまでにこのセクションを終わらせよう。

あるTokenの視界に入っているTokenやOrnament(つまりHexSpaceObject)のリストをHexSpaceに問い合わせることができるようにしよう。もちろんプレイヤーはHex空間を俯瞰するだろうが、例えばストラテジーゲームのモンスターなどはその限りではない。遮蔽の概念があったほうが面白いゲームというものもある。ちなみに私が今作っているゲームは「自動化されたBotが自動的に戦闘を行う」という感じのものなので、視界の概念は重要な気がする。

視界と一口にいっても、可視光の範囲がどれくらいか、対象とする物体は光学迷彩を持つのかどうか、などいろいろと検討すべきことはある。もっと言えば聴覚や嗅覚で対象を認識する存在も想定できる。現実的な課題をどうゲームで抽象化するのかは、ゲームデザイナーの腕の見せ所なのだろうが、とりあえずのところはたいだい大雑把にいけそうな感じに抽象化して実装することにする。

まず必要な構造体を定義する。

struct HexSpaceSight {
   var direction: HexDirection
   var angle: Int // 1 ~ 6, means viewing angle. (culc angle * 60°)
   var hexRange: Int
}

HexSpaceSightは視界を表現する。まあ視界じゃなくて「方向付きの認知範囲」っていうほうが正しいので、そのうち名前を変更するかもしれない。
angle は 1 〜 6 の間の整数を期待するが、それ以外の値がセットされていても動作するようにしておいたほうが良さそう。angleが1上昇するごとに、視野角が60度ずつ増えていく。

あとは適当に当たり判定を実装してやる。

class HexSpace {
   
   let cells: Array<HexCell>
   var objects: Array<HexSpaceObject> = []
   var positions: Dictionary<Int, HexCoordinate> = [:]
   
   func coordinate(of object: HexSpaceObject) -> HexCoordinate {
       return positions[object.hexId]!
   }
   
   func find(in sight: HexSpaceSight, from object: HexSpaceObject) -> Array<HexSpaceObject> {
       return objects
           .filter { self.coordinate(of: $0).isVisible(in: sight, source: self.coordinate(of: object)) }
   }
}

extension HexCoordinate {
   
   func isVisible(in sight: HexSpaceSight, source: HexCoordinate) -> Bool {
       
       if self.isNearBy(source, hexRange: sight.hexRange) == false {
           return false // target is out of viewing range.
       }
       
       if self.isInSight(sight, source: source) == false {
           return false // target is not in viewing angle.
       }
       
       return true
   }
}

こういう試作品みたいなものを作るのにAPIの美しさを追求するの、本当は良くないなと思いつつ、趣味なのでやってしまっている。

HexCoordinateのisNearby(:hexRange:)は、自分と相手の距離がhexRangeマス以内にあることを確かめるメソッド。isInSight(:source:)は、sourceから見て自分が視界に収まっていることを確かめるメソッド。
つまり、「お互いが視界距離内にいて、相手からみて自分が視野角に収まっている場合、isVisibleである」となる。

それぞれの中身はこんな感じ。

private let HEX_SIZE: Double = 3
private let HEX_WIDTH: Double = sqrt(3) * HEX_SIZE
private let HEX_HEIGHT: Double = 2 * HEX_SIZE
private let HEX_HORIZONTAL_SPACE: Double = HEX_WIDTH
private let HEX_VERTICAL_SPACE: Double = HEX_HEIGHT * 3.0 / 4.0
private let HEX_CENTER_DISTANCE: Double = HEX_WIDTH

private let ε: Double = 0.001

private let ANGLE_REAL_DEGREE: Double = 60

struct RealCoordinate {
   let x: Double
   let y: Double
}

private extension Int {
   var distanceScore: Double { return pow(Double(self) * HEX_CENTER_DISTANCE, 2) }
}

extension HexCoordinate {
   
   var realCoordinate: RealCoordinate {
       return RealCoordinate(
           x: Double(q) * HEX_HORIZONTAL_SPACE + Double(r) * HEX_VERTICAL_SPACE / 2.0,
           y: HEX_VERTICAL_SPACE * Double(r)
       )
   }
   
   func hexDistance(from another: HexCoordinate) -> Int {
       return (abs(q - another.q) + abs(q + r - another.q - another.r) + abs(r - another.r)) / 2
   }
   
   func isNearBy(_ target: HexCoordinate, hexRange range: Int) -> Bool {
       return hexDistance(from: target) <= range
   }
   
   func radian(to target: HexCoordinate) -> Double {
       let sReal = realCoordinate
       let tReal = target.realCoordinate
       return atan2(tReal.y - sReal.y, tReal.x - sReal.x)
   }
   
   func isInSight(_ sight: HexSpaceSight, source: HexCoordinate) -> Bool {
       
       if source == self {
           return true
       }
       
       if sight.angle <= 0 { // no sight.
           return false
       }
       
       if sight.angle >= 6 { // 360° over viewing angle.
           return true
       }
       
       let realRadian = source.radian(to: self)
       let directionRadian = HexCoordinate.zero.radian(to: sight.direction.vector)
       
       let viewingAngleRadian = (Double(sight.angle) * ANGLE_REAL_DEGREE / 360.0 * 2.0 * .pi) // angle is Int, 1 angle equals 60°
       let lhs = directionRadian - viewingAngleRadian / 2.0
       let rhs = directionRadian + viewingAngleRadian / 2.0
       
       if lhs <= realRadian + ε, realRadian - ε <= rhs {
           return true
       }
       
       if .pi * 2.0 < rhs { // Really necessary?
           let test = rhs - .pi * 2.0
           if 0.0 <= realRadian, realRadian <= test + ε {
               return true
           }
       }
       
       if .pi * -2.0 > lhs { // Really necessary?
           let test = lhs + .pi * 2.0
           if test - ε <= realRadian, realRadian <= 0.0 {
               return true
           }
       }
       
       return false
   }
}

角度は実座標計算しているけど、距離はHexのマスの数を数えている。実座標計算といっても、60度刻みにしているので、Hex座標系とのズレがあるわけではないと思う。

これでTokenの視界の基礎ができた。ここに、遮蔽のルールを追加すれば完成になるが、一旦後回しにする。

テストコードはこちら。

class HexSpaceSightTests: XCTestCase {
   
   class Bot: HexSpaceObject {
       var hexId: Int = 0
       let isToken = true
   }
   class Cube: HexSpaceObject {
       var hexId: Int = 0
       let isToken = false
   }
   
   var space: HexSpace!
   var bot: Bot!
   var cube1: Cube!
   var cube2: Cube!
   
   override func setUp() {
       let factory = HexSpaceFactory()
       factory.addHexagram(center: HexCoordinate(q: 0, r: 0), range: 2)
       self.space = HexSpace(use: factory)
       
       XCTAssertEqual(space.cells.count, 7)
       
       bot = Bot()
       space.place(bot, at: HexCoordinate(q: 0, r: 0))
       
       cube1 = Cube()
       space.place(cube1, at: HexCoordinate(q: 1, r: 0)) // Right
       
       cube2 = Cube()
       space.place(cube2, at: HexCoordinate(q: -1, r: 0)) // Left
   }
   
   func testObjectSight1() {
       let sight = HexSpaceSight(direction: .right, angle: 3, hexRange: 1) // 180°
       let result = space.find(in: sight, from: bot)
       
       XCTAssertTrue(result.contains(where: { $0 === cube1 }))
       XCTAssertFalse(result.contains(where: { $0 === cube2 }))
   }
   
   func testObjectSight2() {
       let sight = HexSpaceSight(direction: .upRight, angle: 3, hexRange: 1) // 180°
       let result = space.find(in: sight, from: bot)
       
       XCTAssertTrue(result.contains(where: { $0 === cube1 }))
       XCTAssertFalse(result.contains(where: { $0 === cube2 }))
   }
   
   func testObjectSight3() {
       let sight = HexSpaceSight(direction: .downRight, angle: 3, hexRange: 1) // 180°
       let result = space.find(in: sight, from: bot)
       
       XCTAssertTrue(result.contains(where: { $0 === cube1 }))
       XCTAssertFalse(result.contains(where: { $0 === cube2 }))
   }
   
   func testObjectSight4() {
       let sight = HexSpaceSight(direction: .left, angle: 3, hexRange: 1) // 180°
       let result = space.find(in: sight, from: bot)
       
       XCTAssertFalse(result.contains(where: { $0 === cube1 }))
       XCTAssertTrue(result.contains(where: { $0 === cube2 }))
   }
   
   func testObjectIsInCornerOfSight() {
       let sight = HexSpaceSight(direction: .upRight, angle: 2, hexRange: 1) // 120°
       let result = space.find(in: sight, from: bot)
       
       XCTAssertTrue(result.contains(where: { $0 === cube1 }))
       XCTAssertFalse(result.contains(where: { $0 === cube2 }))
   }
}

ジェノベーゼパスタおいしい。

HexSpaceの実装:経路探査

キャラクターをある場所からある場所に移動させたい時、経路を割り出さなければならない。経路の割り出しは結構面倒くさそうである。ある場所に向かうための経路が複数存在する可能性もある。複数あったらどうするんだろう。

経路探索でよく聞くアルゴリズムは、1マスずつ塗っていくやり方だ。塗っていくっていうか、こう、例えば「全方位に1マス移動して、そのマス全部に1とマークを付ける」みたいな。移動する度にマークを増やしていくが、すでにマークを塗っている場所はカウントしない。例えば移動可能距離が4マスなら、この処理を4回繰り返す。マークをつけたマスは、4ステップ以内に移動できるマスだ。
参考にしているサイトにも書いてある: https://www.redblobgames.com/grids/hexagons/#range-obstacles

まあ大雑把な理屈がわかれば細かいところを適宜対応するだけなので、適当に実装してみる。

class HexSpace {
   
   func checkCellExists(at coordinate: HexCoordinate) -> Bool {
       return cells.contains { $0.coordinate == coordinate }
   }
   
   func coordinate(of object: HexSpaceObject) -> HexCoordinate {
       return positions[object.hexId]!
   }
   
   func findPath(maxRange: Int, from sourceCoordinate: HexCoordinate, to targetCoordinate: HexCoordinate) -> Array<HexCoordinate>? {
       
       guard checkCellExists(at: sourceCoordinate) else { return nil }
       
       if sourceCoordinate.hexDistance(from: targetCoordinate) > maxRange {
           return nil
       }
       
       var workspace: Array<Array<HexCoordinate>> = [[sourceCoordinate]]
       var finishStep: Int? = nil
       var step = 0
       
       while finishStep == nil && step <= maxRange {
           
           let nextCoordinates = workspace[step]
               .flatMap { $0
                   .aroundCoordinates
                   .filter { self.checkCellExists(at: $0) }
                   .filter { !workspace.flatMap { $0 }.contains($0) }
               }
           workspace.append(nextCoordinates)
           step += 1
           
           if nextCoordinates.contains(targetCoordinate) {
               finishStep = step
           }
       }
       
       if let finishStep = finishStep {
           var path: Array<HexCoordinate> = [targetCoordinate]
           var step = finishStep - 1 // step is not equal finishStep.
           while step >= 0 {
               defer { step -= 1 }
               let next = workspace[step].first { $0.isTouching(to: path.last!) }
               path.append(next!)
           }
           return path.reversed()
       }
       
       return nil
   }
}

ふう。たまに配列のoutOfRangeエラーが出そうな雰囲気が出てるけど、まあとりあえずはこんなものだと思う。ステップごとにworkspaceに整理していって、対象を見つけたら逆向きに辿っている。

テストはこんな感じ。

class HexSpacePathTests: XCTestCase {
   
   class Bot: HexSpaceObject {
       var hexId: Int = 0
       let isToken = true
   }
   
   var space: HexSpace!
   var bot: Bot!
   
   override func setUp() {
       let factory = HexSpaceFactory()
       factory.addHexagram(center: HexCoordinate(q: 0, r: 0), range: 4)
       self.space = HexSpace(use: factory)
       
       XCTAssertEqual(space.cells.count, 37)
       
       bot = Bot()
       space.place(bot, at: HexCoordinate(q: 0, r: 0))
   }
   
   func testPath() {
       let path = space.findPath(maxRange: 3, from: HexCoordinate(q: 0, r: 0), to: HexCoordinate(q: 3, r: 0))
       
       XCTAssertNotNil(path)
       XCTAssertEqual(path!.count, 4)
   }
   
   func testShortPath() {
       let path = space.findPath(maxRange: 1, from: HexCoordinate(q: 0, r: 0), to: HexCoordinate(q: 1, r: 0))
       
       XCTAssertNotNil(path)
       XCTAssertEqual(path!.count, 2)
   }
   
   func testOutOfRange() {
       let path = space.findPath(maxRange: 1, from: HexCoordinate(q: 0, r: 0), to: HexCoordinate(q: 2, r: 0))
       
       XCTAssertNil(path)
   }
}

HexSpaceの実装:経路探査を評価する

ゲームとしてすべての経路を使えるとは限らない。

たとえばシミュレーションゲームであれば、移動経路ごとにキャラクターが支払う移動コストが存在する。平地は岩場よりも歩きやすいとか、高いところから飛び降りる時と高いところに上がる時でコストが違うとか、そういうルールだ。また、「味方同士のキャラクターは通れるが、敵同士のキャラクターは通さない」みたいなルールもあり得るだろう。

現在は複数の経路がある時は、その中から一つを返している。findPathの後半にあるこのコードを見てほしい。

       if let finishStep = finishStep {
           var path: Array<HexCoordinate> = [targetCoordinate]
           var step = finishStep - 1 // step is not equal finishStep.
           while step >= 0 {
               defer { step -= 1 }
               let next = workspace[step].first { $0.isTouching(to: path.last!) } // ← これ大事
               path.append(next!)
           }
           return path.reversed()
       }

let next = workspace[step].first { $0.isTouching(to: path.last!) } ← 重要なのはこれだ。

この手順では、目標地点から開始地点に向かって逆順に経路をたどっている。workspaceのindexはそのまま「開始地点から最短何度目の移動でそのマスにたどり着けるか」を表している。例えばworkspace[1]には、開始地点から1回移動して入れる座標のリストがある。
目標地点まで最短でN回でたどり着けるとする。(プログラム上はfinishStep)目標地点の周囲にあるマスのうち、N-1回でたどり着けるマスがあれば、そこが一つ前の地点である。したがって、workspace[N-1]にある座標の配列から、目標地点に隣接している(isTouched)座標を探し、最初に発見したものを経路として選択する。そういう実装だ。
.first(where:)は条件を満たす要素を探し、最初の一つを見つけた時点でそれを返すメソッドである。

しかし、実際には経路が複数存在する可能性がある。

画像1

このグラフを見るとなるほどとなる。参考サイトのスクリーンショットなんだけど、よくよく見れば表示されている以外にも経路がある。6(目標地点)から左下にある5に移動しているが、左に移動しても経路としては成立する。5→4、4→3にもそれぞれ分岐があるので、4通りの経路が存在することになる。

今は「すべてのマスの移動コストが同じ」ものとして実装しているが、実際にゲームにする時は経路ごとにコストが異なるだろう。移動コストが違うのなら、当然キャラクターは「自然と移動コストが最も低い経路を選択する」ことが望ましい。しかし、HexSpaceがたまたま見つけた経路だけを返していては、複数の経路の移動コストを計算することが出来ない。(そもそも複数の経路が存在することを知ることができない!)

これは困るので、直そう。

typealias HexPath = Array<HexCoordinate>

class HexSpace {

    func findPaths(maxRange: Int, from sourceCoordinate: HexCoordinate, to targetCoordinate: HexCoordinate) -> Array<HexPath>? {
       
        guard checkCellExists(at: sourceCoordinate) else { return nil }
       
        if sourceCoordinate.hexDistance(from: targetCoordinate) > maxRange {
            return nil
        }
        
        var fringes: Array<Array<HexCoordinate>> = [[sourceCoordinate]]
        var visited: Set<HexCoordinate> = []
        var step = 0
       
        while visited.contains(targetCoordinate) == false && step <= maxRange {
            defer { step += 1 }
            fringes.append([])
            fringes[step].forEach { coordinate in
                coordinate.aroundCoordinates
                    .filter { self.checkCellExists(at: $0) }
                    .forEach { coordinate in
                        visited.insert(coordinate)
                        fringes[step + 1].append(coordinate)
                    }
            }
        }
       
        guard visited.contains(targetCoordinate) else { return nil }

        let lastStep = step
       
        do {
            var paths: Array<HexPath> = [[targetCoordinate]]
            var step = lastStep - 1
            
            while step >= 0 {
                defer { step -= 1 }
                let count = paths.count
                for index in 0..<count {
                    let path = paths[index]
                    let nextCoordinates = fringes[step].filter { $0.isTouching(to: path.last!) }
                    
                    paths[index] = path + [nextCoordinates.first!]
                    
                    if nextCoordinates.count > 1 {
                        for i in 1..<nextCoordinates.count {
                            paths.append(path + [nextCoordinates[i]])
                        }
                    }
                }
            }
            return paths.map { $0.reversed() }
        }
    }
}

ついでにちょっとリファクタリングもした。参考ブログのコードに寄せたほうがいい感じになった。

複数経路を返すテストも記述したいし、メソッドのインターフェイスが変わったのでそれに合わせてテストも直す。

class HexSpacePathTests: XCTestCase {
   
   class Bot: HexSpaceObject {
       var hexId: Int = 0
       let isToken = true
   }
   
   var space: HexSpace!
   var bot: Bot!
   
   override func setUp() {
       let factory = HexSpaceFactory()
       factory.addHexagram(center: HexCoordinate(q: 0, r: 0), range: 4)
       self.space = HexSpace(use: factory)
       
       XCTAssertEqual(space.cells.count, 37)
       
       bot = Bot()
       space.place(bot, at: HexCoordinate(q: 0, r: 0))
   }
   
   func testPath() {
       let paths = space.findPaths(maxRange: 3, from: HexCoordinate(q: 0, r: 0), to: HexCoordinate(q: 3, r: 0))
       
       XCTAssertNotNil(paths)
       XCTAssertFalse(paths!.isEmpty)
       
       paths?.forEach { path in
           XCTAssertEqual(path.count, 4)
       }
   }
   
   func testShortPath() {
       let paths = space.findPaths(maxRange: 1, from: HexCoordinate(q: 0, r: 0), to: HexCoordinate(q: 1, r: 0))
       
       XCTAssertNotNil(paths)
       XCTAssertFalse(paths!.isEmpty)
       
       paths?.forEach { path in
           XCTAssertEqual(path.count, 2)
       }
   }
   
   func testOutOfRange() {
       let paths = space.findPaths(maxRange: 1, from: HexCoordinate(q: 0, r: 0), to: HexCoordinate(q: 2, r: 0))
       
       XCTAssertNil(paths)
   }
   
   func testMultipleResult() {
       let paths = space.findPaths(maxRange: 4, from: HexCoordinate(q: 0, r: 0), to: HexCoordinate(q: 2, r: -1))
       
       XCTAssertNotNil(paths)
       XCTAssertFalse(paths!.isEmpty)
       XCTAssertEqual(paths!.count, 2)
       
       paths?.forEach { path in
           XCTAssertEqual(path.count, 3)
       }
   }
}

経路を評価するコードはHexSpaceの利用側に実装すれば良いので、ここではやらない。

HexSpaceの実装:壁

経路探査ができるようになって、視界の判定ができるようになった。次は壁である。壁は「Cellが壁かどうか」だけを扱うことにする。

struct Field {
   let isWall: Bool
   let floorHeight: Int
   
   static let none = Field(isWall: false, floorHeight: 0)
}

壁の大きさはHexより小さくしておく。しかし、壁であるCellが隣接していると、そのHexそれぞれの壁(というか柱?)は連結して一つになる。

こういうルール、どっかで見たことあるな……。

投げ縄ツールだ。

さて、壁の処理についてはいくつか要件がある。事前に整理しておこう。

まず壁の厚みはHexよりも小さい。Hexの辺の長さを3とすると、辺と辺の間の間隔は3×√3、向かい合う頂点の距離は6になる。壁の厚みはHexの辺の長さ、すなわち3に揃えることにするが、この数値はなにかの定数としておくのが無難かもしれない。

壁をグラフィックでレンダリングする場合、壁がどのように接続されているのかを知る必要がある。ひとまず隣り合う壁は自動的に接続されるものとするが、ゲームによっては壁が自動的に接続されると都合が悪いこともあるかもしれない。そういう時は実装を修正することになる。どちらにせよ、壁の「塊」の一覧を取得する方法がHexSpaceから提供されるべきだろう。

視界に入るHexSpaceObjectの一覧を取得するメソッドfindObjectsをすでに実装したが、これはいまのところ「壁による遮蔽」に対応していない。findObjectsで見つけたHexSpaceObjectそれぞれについて、起点から対象までのラインが壁にぶつかっていないことを確認する必要がある。(ゲームによっては「部分遮蔽」を検討しなければならないかもしれない)
当然、壁による視界への遮蔽は、壁の塊に対して働く。

要件が整理できたので実装しよう。

typealias HexWallBlock = Set<HexCoordinate>

class HexSpace {

    func findWallBlocks() -> Array {
       
       let walls = cells
           .filter { $0.field.isWall }
           .map { $0.coordinate }
       
       var results: Array = []
       var used: Set = []
       
       while used.count < walls.count {
           guard let target = walls.filter({ !used.contains($0) }).first else { break }
           used.insert(target)
           
           var wallBlock: HexWallBlock = [target]
           
           while let target = walls.first(where: { test in wallBlock.contains(where: { test.isTouching(to: $0) }) && !used.contains(test) }) {
               wallBlock.insert(target)
               used.insert(target)
           }
           
           results.append(wallBlock)
       }
       
       return results
   }
}

うーん。もうちょっと綺麗に実装できないんですかね。

wallBlock.contains.... の部分が野暮ったいので綺麗にした。

extension HexWallBlock {
   
   func isTouched(by coordinate: HexCoordinate) -> Bool {
       return contains { $0.isTouching(to: coordinate) }
   }
}

class HexSpace {​
    func findWallBlocks() -> Array<HexWallBlock> {
       
       let walls = cells
           .filter { $0.field.isWall }
           .map { $0.coordinate }
       
       var results: Array<HexWallBlock> = []
       
       var usedList: Set<HexCoordinate> = []
       let use: (HexCoordinate) -> Void = { usedList.insert($0) }
       let used: (HexCoordinate) -> Bool = { usedList.contains($0) }
       
       while usedList.count < walls.count {
           guard let target = walls.filter({ !used($0) }).first else { break }
           use(target)
           
           var wallBlock: HexWallBlock = [target]
           
           while let target = walls.first(where: { wallBlock.isTouched(by: $0) && !used($0) }) {
               wallBlock.insert(target)
               use(target)
           }
           
           results.append(wallBlock)
       }
       
       return results
   }
}

まあかろうじてまとも。​useとusedをクロージャにしたのが良いのか悪いのかって感じだ。まあ動くので良し。壁の塊の一覧を取得できるようになったので、連結したグラフィックにするのはクライアントコード任せとする。

次は視界と遮蔽だ。

起点から目標が遮られているかどうか、という問題をどう解決するのが良いだろうか。まあいろいろアプローチはありそうだけど、とりあえず「起点から目標までの線分が通るHexに壁が含まれていれば、遮蔽されている」としてみよう。

素直にこれを参考にする: https://www.redblobgames.com/grids/hexagons/#line-drawing

(ちなみに、壁がCellと同じ大きさの場合、 https://www.redblobgames.com/grids/hexagons/#field-of-view これが使える)

とりあえずRealCoordinateをHexCoordinateに変換するコードを書いて(あとyの符号ミスってたのでそれもなおして)

struct RealCoordinate {
   let x: Double
   let y: Double
   
   func roundHexCoordinate() -> HexCoordinate {
       let r = round(-y / HEX_VERTICAL_SPACE)
       let q = round((x - r * HEX_HORIZONTAL_SPACE / 2.0) / HEX_HORIZONTAL_SPACE)
       return HexCoordinate(q: Int(q), r: Int(r))
   }
}

そんで、起点から目標までの線分がヒットするHex座標をリストアップして、適当に壁の有無で判定してみる。

extension HexWallBlock {
   
   func isInterrupting(from sourceCoordinate: HexCoordinate, to targetCoordinate: HexCoordinate) -> Bool {
       let distance = sourceCoordinate.hexDistance(from: targetCoordinate)
       let sReal = RealCoordinate(from: sourceCoordinate)
       let tReal = RealCoordinate(from: targetCoordinate)
       let diff = RealCoordinate(x: sReal.x - tReal.x, y: sReal.y - tReal.y)
       let count = distance + 1
       
       let targets = Set((0...count).map { _ in
           let cReal = RealCoordinate(
               x: sReal.x + diff.x / Double(count),
               y: sReal.y + diff.y / Double(count)
           )
           return cReal.roundHexCoordinate()
       })
       
       return targets.contains { self.contains($0) }
   }
}

まあ動いた。

そして実際には壁には厚みがあるので、それについても考慮する。……え? 線と円の距離を求める計算、ベクトル必要では……? ベクトルなんて使いたくなかった。ていうかSwiftのベクトル、Floatじゃん。ここまでDouble使ってるんだけど……。仕方ないのでRealCoordinateをVector扱いして計算しておこう。

extension HexWallBlock {
   
   func isInterrupting(from sourceCoordinate: HexCoordinate, to targetCoordinate: HexCoordinate) -> Bool {
       let distance = sourceCoordinate.hexDistance(from: targetCoordinate)
       let sReal = RealCoordinate(from: sourceCoordinate)
       let tReal = RealCoordinate(from: targetCoordinate)
       let diff = tReal - sReal
       let count = distance
       
       let targets = Set((0...count).map { i in
           let cReal = RealCoordinate(
               x: sReal.x + diff.x / Double(count) * Double(i),
               y: sReal.y + diff.y / Double(count) * Double(i)
           )
           return cReal.roundHexCoordinate()
       }).filter { self.contains($0) }
       
       if targets.count == 0 {
           return false
       }
       
       return targets.contains { target in
           let cReal = RealCoordinate(from: target)
           let vector = cReal - sReal
           return (vector.length * sin(atan2(vector.x, vector.y))).magnitude < HEX_SIZE / 2.0
       }
   }
}

これだと壁と壁の繋がりが計算できてないので、それも追加する。……え? 線分同士の衝突判定ってことは、線分の定義が必要では!?!?

extension HexWallBlock {
   
   func isInterrupting(from sourceCoordinate: HexCoordinate, to targetCoordinate: HexCoordinate) -> Bool {
       let distance = sourceCoordinate.hexDistance(from: targetCoordinate)
       let sReal = RealCoordinate(from: sourceCoordinate)
       let tReal = RealCoordinate(from: targetCoordinate)
       let diff = tReal - sReal
       let count = distance
       
       let targets = Set((0...count).map { i in
           let cReal = RealCoordinate(
               x: sReal.x + diff.x / Double(count) * Double(i),
               y: sReal.y + diff.y / Double(count) * Double(i)
           )
           return cReal.roundHexCoordinate()
       }).filter { self.contains($0) }
       
       if targets.count == 0 {
           return false
       }
       
       return targets.contains { target in
           let cReal = RealCoordinate(from: target)
           let vector = cReal - sReal
           
           // conflict the center circle of wall.
           if (vector.length * sin(atan2(vector.x, vector.y))).magnitude < WALL_SIZE / 2 {
               return true
           }
           
           let arounds = target.aroundCoordinates.filter { self.contains($0) }
           return arounds.contains { around in
               let s1 = sReal
               let s2 = cReal
               let v1 = tReal - s1
               let v2 = RealCoordinate(from: around) - s2
               let v = s2 - s1
               let cross: (RealCoordinate, RealCoordinate) -> Double = { $0.x * $1.y - $0.y * $1.x }
               let t1 = cross(v, v1) / cross(v1, v2)
               let t2 = cross(v, v2) / cross(v1, v2)
               return 0.0 <= t1 && t1 <= 1.0 && 0.0 <= t2 && t2 <= 1.0
           }
       }
   }
}

気が狂いそう。

線分の衝突判定とか自作する意味薄いので、良い子のみんなはこんなコード書かないようにしようね。後半に露骨な疲労困憊具合が見える。あとパフォーマンスやばそう。

ともあれこれで壁がいい感じに動作するようになったと思う。以下テスト。

class HexSpaceWallBlocksTests: XCTestCase {
   
   class Bot: HexSpaceObject {
       var hexId: Int = 0
       let isToken = true
   }
   
   var space: HexSpace!
   var bot: Bot!
   
   override func setUp() {
       let factory = HexSpaceFactory()
       factory.addHexagram(center: HexCoordinate(q: 0, r: 0), range: 4)
       factory.addLine(start: HexCoordinate(q: -3, r: 1), direction: .right, length: 5) { cell in
           cell.setField(Field(isWall: true, floorHeight: 0))
       }
       factory.addLine(start: HexCoordinate(q: -1, r: -2), direction: .right, length: 5) { cell in
           cell.setField(Field(isWall: true, floorHeight: 0))
       }
       self.space = HexSpace(use: factory)
       
       XCTAssertEqual(space.cells.count, 37)
       
       bot = Bot()
       space.place(bot, at: HexCoordinate(q: 0, r: 0))
   }
   
   func testWallBlocks() {
       let wallBlocks = space.findWallBlocks()
       XCTAssertEqual(wallBlocks.count, 2)
   }
   
   func testInteruppting() {
       
       let wallBlocks = space.findWallBlocks()
       XCTAssertEqual(wallBlocks.count, 2)
       
       do {
           let source = HexCoordinate(q: 0, r: 0)
           let target = HexCoordinate(q: 1, r: 0)
           let result = wallBlocks.contains { $0.isInterrupting(from: source, to: target) }
           XCTAssertFalse(result)
       }
       
       do {
           let source = HexCoordinate(q: 0, r: 0)
           let target = HexCoordinate(q: -1, r: -2)
           
           let result = wallBlocks.contains { $0.isInterrupting(from: source, to: target) }
           XCTAssertTrue(result)
       }
       
       do {
           let source = HexCoordinate(q: 0, r: 0)
           let target = HexCoordinate(q: 1, r: -3)
           
           let result = wallBlocks.contains { $0.isInterrupting(from: source, to: target) }
           XCTAssertTrue(result)
       }
   }
}

SIMDを使う

ここまで愚直にstructとか自作して実装してきたけど、Swift5にはSIMDというすごいいいものがある。これを使うように修正してみたい。

パフォーマンスはちょっと計測してみたけどあんま変わらなかった。かなしい。Optimization Level によっては大きく変わるかもしれない。

/// Cross product of Vector.
infix operator **

private typealias Vector = SIMD2<Double>
private typealias Point = SIMD2<Double>

extension SIMD2 where Scalar == Double {
   
   fileprivate init(from s: Point, to t: Point) {
       self = t - s
   }
   
   fileprivate init(from c: HexCoordinate) {
       let x = c.qd * HEX_HORIZONTAL_SPACE + c.rd * HEX_HORIZONTAL_SPACE / 2.0
       let y = HEX_VERTICAL_SPACE * -c.rd
       self.init(x, y)
   }
   
   fileprivate static func **(lhs: SIMD2<Scalar>, rhs: SIMD2<Scalar>) -> Scalar {
       return lhs.x * rhs.y - lhs.y * rhs.x
   }
   
   fileprivate func roundHexCoordinate() -> HexCoordinate {
       let r = Darwin.round(-y / HEX_VERTICAL_SPACE)
       let q = Darwin.round((x - r * HEX_HORIZONTAL_SPACE / 2.0) / HEX_HORIZONTAL_SPACE)
       return HexCoordinate(q: Int(q), r: Int(r))
   }
   
   fileprivate var vectorLength: Scalar { return sqrt(pow(x, 2) + pow(y, 2)) }
}

private struct Segment {
   let s: Point
   let v: Vector
   
   init(_ s: Point, _ t: Point) {
       self.s = s
       self.v = Vector(from: s, to: t)
   }
   
   // return true, when segment is crossing.
   static func ^(lhs: Segment, rhs: Segment) -> Bool {
       let v = Vector(from: lhs.s, to: rhs.s)
       let t1 = (v ** lhs.v) / (lhs.v ** rhs.v)
       let t2 = (v ** rhs.v) / (lhs.v ** rhs.v)
       return 0.0 <= t1 && t1 <= 1.0 && 0.0 <= t2 && t2 <= 1.0
   }
}

外積の演算子を**にしたけど、一般的にどれを使うみたいなのあるんだろうか。数学では[a,b]みたいなのを見た覚えがある気がする。Segment^Segmentは交点の有無にした。

壁が視界を塞いでいるかどうかの判定部分がかなり変わった。

extension HexWallBlock {
    
    func isInterrupting(from sourceCoordinate: HexCoordinate, to targetCoordinate: HexCoordinate) -> Bool {
        let distance = sourceCoordinate.hexDistance(from: targetCoordinate)
        let sReal = Point(from: sourceCoordinate)
        let tReal = Point(from: targetCoordinate)
        let diff = Vector(from: sReal, to: tReal)
        let count = distance
       
        let targets = Set((0...count).map { ( sReal + ( diff / Double(count) * Double($0) ) ).roundHexCoordinate() })
            .filter { self.contains($0) }
       
        if targets.count == 0 {
            return false
        }
        
        return targets.contains { target in
            let cReal = Point(from: target)
            let vector = Vector(from: cReal, to: sReal)
            
            // conflict the center circle of wall.
            if (vector.vectorLength * sin(atan2(vector.x, vector.y))).magnitude < WALL_SIZE / 2 {
                return true
            }
           
            let arounds = target.aroundCoordinates.filter { self.contains($0) }
            return arounds.contains { around in
                let s1 = Segment(sReal, tReal)
                let s2 = Segment(cReal, Point(from: around))
                return s1 ^ s2 // return true, when segment is crossing.
            }
        }
    }
}

(vector.vectorLength * sin(atan2(vector.x, vector.y)))もなんか簡単にできそうだけどとりあえず考えないでおこう。PointとVectorが同じだけど別の名前で扱ってるのでちょっとくらい見通しがよくなったようなきがする……?

最後

整理したコードはGistにおいてる。

とりあえず大雑把にはできたので、外部から処理を注入する方法をいろいろと検討すれば、ゲームロジック本体から利用できるHex座標系が完成する。けどまあ多分、使ってたら「こういうのほしい」ってのがいっぱい出てくると思う。

この記事が気に入ったらサポートをしてみませんか?