月: 2023年6月

ゼルダの伝説ティアーズ・オブ・ザ・キングダム

クリアしました。プレイ時間70時間ぐらい。

なんというか、たいへんにすごくて、あまり類のないタイプのゲームであり、いやまあすごかったなと思った。がなんというか、ゲームの要素がどれもこれも自分にはいまひとつフィットしなくて、うーんこれは自分向きではないな……という違和感というかなんというか、そういうものをずっと思ってたゲームでもあった。そういう気持ちはエンディングまで解消されなかったかな。

たとえばなんだけど、ウルトラハンドによっていろんなものを構築できる能力。ネットのいろんな記事なりなんなりを眺めていると、とにかくみんなこの能力をつかっていろんなものを作って楽しんでいるようだ。が、自分はいっさいそういうことをする気になれなかった。ゲーム内の障害を解決するために、パズルを解くためにいろいろ考えるのは好きだしやるけど、そうでもないところで何かをやってみようという気持ちがいっさい湧かない。馬車的なものも(大妖精クエスト以外では)一度もつくらなかったし、ほとんどの移動は徒歩とワープだけでこなした。そういうものに興味が出てこないんだよね。マインクラフトに興味がわかないのと同じだな。

スクラビルドも同様で、ああこれは無限に強化アイテムが湧いてくるから適当な武器を強化して消費していくゲームなんだな、ということはまあわかる。わかるんだけど、はっきりいってめんどくさいなと思う。ここに面白さを感じることはついぞなかった。あとどうでもいい話なんだけど、主人公は特殊能力で武器にいろいろくっつけられるという設定なのに、そのへんの雑魚的もいろいろも武器にくっつけられるのはどうなってるんだ。

戦闘のアクション。今回はいつものゼルダよりも前作ブレス・オブ・ザ・ワイルドよりもだいぶ戦闘のアクション要素が高まっているように感じた。作ってるほうとしては折角いろんな戦闘用の動作や要素を盛り込んだのにそんなに活用されないのはもったいないといった面があるのかもしれないけど、正直こういうのあんまり得意じゃないというのもあるし、かったるいし、たいへん。とくに終盤の展開というかラストバトル周辺はほんとうにうんざりした。そして自分はゼルダにそういうことを求めていないという気持ちが高まった。

メインクエストや祠などのパズルっぽいタスクは楽しめるし、ウオトリー村の復興タスクとかはけっこう楽しい(海賊退治はめんどくさくてイヤイヤこなした)。メインクエストのボス戦もしんどい奴はいるけどそんなに嫌いじゃない。つまり自分はパズル的な要素とかギミックとかをゼルダに求めているのだな、という気がする。本作は、全体的にパズルっぽさはずいぶん抑えられていたように思う。それよりは戦闘と、工作による創意工夫が強調されていたかんじ。そのへんがnot for meなかんじだったな。あと前作もそうだったけど祠をさがすのがかったるすぎる。コログの運搬もすぐ飽きてスルーしてた。全般的にやりこみ要素に興味がないのだよね。でもBotW以降のゼルダというのはやりこみ要素だけで構成されてるようなゲームなんだよな。

ストーリー。まぁゼルダというのはストーリーはあってなきがごとしというか、メインのゲーム要素に対する刺身のツマ的な意味しかないと思うけど、でもなんかいまいちだなーと思った。全般的なバックストーリーがそのまんま天孫降臨でキーアイテムが勾玉のかたちなのも、わざとな含意じゃないだろうけどちょっとたじろいてしまったし、賢者の能力がクライマックスの展開にいまいち絡んでこないのも今ひとつ白ける。賢者として覚醒したから魔王を倒せた、みたいなストーリーになっていない気がする。サブクエストのストーリーもそこまで興味がわかないし、サブクエストの報酬が全体的にしょっぱくてやるモチベーションが薄かった。すごいアイテムをもらってもすぐ壊れるか消費しちゃうかだし、経験値とか成長要素は基本的にはないし……。

まぁいろいろ書いたわりには70時間以上プレイしているわけだし、全体的には面白いゲームなんですよ。でもやはり、not for meなところが強かったですね、という気持ち。今後のゼルダは基本的にこういう構成になるみたいだし、もう続編やりたくないかもなあとすら思ったかな。

浮動小数点数の加算の順序にハマった話

fediverseにちょっと書いたけど、仕事でちょっとハマって数時間悩んだ話。

とあるコードを書いていて、どうもテストが安定しなくてflakyになる。つまり、通るときは通るがたまに失敗する。が、理由がよくわからない。

こういうとき、Goでよくあるのはmapのイテレーションの順序が意図的にランダム化されているというパターンだ。mapは(内部的にはハッシュテーブルが使われているので)イテレーションの順序は不定であるし、不定であるべきなので順序に依存するコードを書くべきではない。現実的にはたいていの言語ではこのへんは「実装依存である」ということになっていて、うっかりすると言語のランタイムバージョンが上がったり、キーが一個増えたり減ったりするだけで順序が狂い、突然テストが死ぬみたいな現象によくみまわれる。Goでは意図的にこの問題に対処するために、言語のバージョンが変わらなくても毎回イテレーションの順番かわることになっている。実際、同じバイナリで同じmapをfor-loopで巡回するだけでも、毎回結果は異なったりする。たとえば次みたいに。

https://go.dev/play/p/fm_oDaAeXpI

ただ自分はそのことはよく知っているし、ここでいじってるコードのイテレーションの順番はわりと大事なのできちんと順序を保っているつもりでいた。それか、イテレーションの順番に影響なさそうな単純な集計だ。なのに微妙に結果がかわることがある。わけがわからない。困ったな……と思っていた。

そこでいろんなところにhackyな変更を入れたりしてデバッグしていたのだけど、しばらくしてひらめいて正しい修正ができたのだった。

実際にはコードのなかでは「単純な集計」をしている箇所があって、そのなかの集計のひとつはmapのなかの浮動小数点数を合計するというものだった。単純化するとこんな感じ。

func getTotal(m map[string]float64) float64 {
  var total float64
  for _, v := range m {
    total += v
  }
  return total
}

これは一見問題ないように見える。単純に合計しているだけだし。でもこういう計算が実はflakinessの原因になっていた。

浮動小数点数では大きな数と小さな数のあいだの演算をすると小さな数の下位ビットの情報が抜けおちるように計算される。そのため、上のコードのmのなかに大きな数値と小さな数値が混在していると、イテレーションの順番によって結果が微妙に異なってしまうというケースがありうる。これまた単純化された事例だけど、mapのsliceがあって、そのなかをスキャンして最大値をとるもののインデックスを知りたい、みたいなことがあったとしても、次のようにまったく同じmapなのにインデックスが変わってしまう、みたいなことがありうる。今回遭遇したのもだいぶ単純化されてるけどこういうパターン。

https://go.dev/play/p/HKyvY6BFOnm

ようするに浮動小数点数の加算順序は大事なのを見落していたということだ。たいていの場合、この順番のちがいの結果はほんとうに微差なので、微差を無視するような比較を実装するというのも手だし、getTotalするときの順番はどんなものでもいいがとにかく固定する(たとえばキーをソートしてその順番をつかう)という手もある。今回やったのはfloat64の値をもってきてその値でソートして小さい順に加算していくというもの。下位ビットの欠落が問題であるはずなので、これがまぁたぶんいちばん正しいだろうと思う、が、実際のところはほとんどどうでも良いということになるだろうと思う。

https://go.dev/play/p/HKyvY6BFOnm

というわけで、浮動小数点数の加算による情報の欠落も、mapの順序が不定になることも知ってはいたけれども実際に遭遇するとなかなか見つけづらいバグでした、という話でした。いやぁ浮動小数点数はむずかしいですね。あとあれだな、テストケースにそういうのが見つかるようなケースがあってよかったですね。