Chapter 39

簡易的なシナリオ進行を実装しよう / Goの文字列

eihigh
eihigh
2025.01.31に更新

準備が整ったので、ノベルゲームの核心のプログラミングを始めましょう。ステップバイステップで、どのゲームにも共通する考え方を武器に取り組んでいきます。

設計する

固定的な立ち絵とメッセージを表示するところまでは済んでいるので、次は「どこの値を変数で置き換えたら、ゲームの進行を表現できるか」を考えていきましょう。

まずはシンプルで分かりやすいところから始めたいので、メッセージをクリックに合わせて切り替えるというのはどうでしょうか。

メッセージ切り替えを実装する

text.Draw に渡す文字列が今までは固定でしたが、これを変数で表現すれば切り替えられるようになりそうです。

text.Draw(screen, "吾輩は猫である。名前はまだない。", g.fontFace, textop)
// g.message という変数を新しく用意したとして
text.Draw(screen, g.message, fontFace, textop)

この g.message に入る値をクリックするごとに変えていきたいわけです。最も素直な実現方法は、メッセージのスライスを用意して、クリックするごとにスライスの次の要素を表示するやり方でしょう。例えばこんなふうに変数を用意して、

 type game struct {
 	bg        *ebiten.Image
 	person    *ebiten.Image
 	cat       *ebiten.Image
 	messageBG *ebiten.Image
 
 	fontFace *text.GoTextFace
 
+	scenario []string
+	progress int // シナリオ進行度
+	message  string
 }
 func newGame() (*game, error) {
 	g := &game{}
 
 	// 画像・フォントの読み込みは割愛...
 
+	g.scenario = []string{
+		"吾輩は猫である。",
+		"名前はまだない。",
+	}
 
 	return g, nil
 }

クリックしたら一個ずつメッセージを切り替えます。inpututil.IsMouseButtonJustPressed が真なら g.progress を進めます。インデックスがスライスの要素数以上になるとクラッシュするので、上限に気をつけつつ実装します。

これだと初期状態でメッセージが何もなかったり微妙な挙動になるのですが、後々効いてくるので一旦これで進めさせてください。

 func (g *game) Update() error {
+	if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
+		g.message = g.scenario[g.progress]
+		if g.progress < len(g.scenario)-1 {
+			g.progress++
+		}
+	}
 	return nil
 }
 	// 会話文表示
 	textop := &text.DrawOptions{}
 	textop.GeoM.Translate(200, 480)
 	textop.ColorScale.Scale(0, 0, 0, 1) // 黒
 	textop.LineSpacing = 30 * 1.5
+	text.Draw(screen, g.message, g.fontFace, textop)

クリックでメッセージが切り替わるようになったら、成功です!考えようによっては、もうこれで文章が読めるようになったので、立派なノベルゲームになったと言えないこともないでしょう。ノベルゲームの核は、紐解いてしまえば実はこんなにシンプルなものだったんですね。

立ち絵切り替えを実装する

メッセージの次は立ち絵を切り替えられるようにしましょう。やり方は無数にあるのですが、今回は左右の立ち絵の画像名を変数として持っておくようにしてみます。なお、説明の都合で構造体 chara を使っています。

 type game struct {
 	bg        *ebiten.Image
 	person    *ebiten.Image
 	cat       *ebiten.Image
 	messageBG *ebiten.Image
 
 	fontFace *text.GoTextFace
 
 	scenario   []string
 	progress   int // シナリオ進行度
 	message    string
+	leftChara  chara
+	rightChara chara
 }
 
+type chara struct {
+	name string
+	// 後でフィールドが増えます
+}

描画するとき、画像の名前によってswitchします。

 	// 画像を描画
 	op := &ebiten.DrawImageOptions{}
 	screen.DrawImage(g.bg, op)

 	// 左の立ち絵描画
 	op = &ebiten.DrawImageOptions{}
-	// screen.DrawImage(g.cat, op)
+	switch g.leftChara.name {
+	case "person.png":
+		screen.DrawImage(g.person, op)
+	case "cat.png":
+		screen.DrawImage(g.cat, op)
+	}
 
 	// 右の立ち絵描画
 	op = &ebiten.DrawImageOptions{}
 	op.GeoM.Translate(560, 0)
-	// screen.DrawImage(g.person, op)
+	switch g.rightChara.name {
+	case "person.png":
+		screen.DrawImage(g.person, op)
+	case "cat.png":
+		screen.DrawImage(g.cat, op)
+	}

 	// メッセージ背景描画
 	op = &ebiten.DrawImageOptions{}
 	op.GeoM.Translate(0, 450)
 	op.ColorScale.ScaleAlpha(0.7)
 	screen.DrawImage(g.messageBG, op)

これで、g.leftChara, g.rightCharaname に画像の名前を入れると描画されるようになりました。そしたら name が進行に応じて切り替わるようにします。これも色んな方法が考えられるのですが、今回はシナリオを拡張し、立ち絵切り替え命令を実装する方法を紹介します。

例えば g.scenario をこんな内容にしたとします。

	g.scenario = []string{
		"吾輩は猫である。",
		"rightChara=cat.png",
		"名前はまだない。",
		"吾輩はここで始めて人間というものを見た。",
		"leftChara=person.png",
	}

leftChara=rightChara= はメッセージとして表示する文字列ではなく、立ち絵を変更する「命令」です。シナリオを進めていってこのような = が含まれる要素を発見したら、メッセージを変更する代わりに、命令に応じた処理を行います。やってみましょう。

 func (g *game) Update() error {
 	if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
-		// g.message = g.scenario[g.progress]
+		s := g.scenario[g.progress]
 		if g.progress < len(g.scenario)-1 {
 			g.progress++
 		}

+		before, after, found := strings.Cut(s, "=")
+		if found {
+			switch before {
+			case "leftChara":
+				g.leftChara.name = after
+			case "rightChara":
+				g.rightChara.name = after
+			}
+		} else {
+			g.message = before
+		}
 	}
 	return nil
 }

クリックで立ち絵とメッセージが切り替わっていけば、成功です!🎊

strings.Cut 関数を使って、= という文字が含まれていたら、それより前を命令の種類(leftChara など)、それより後を命令のパラメータ(cat.png など)として処理を行います。含まれていなかったら文字列全体(before に入る)をメッセージとして表示します。

func main() {
	before, after, found := strings.Cut("吾輩は猫である", "=")
	fmt.Println(before, after, found) // "吾輩は猫である", "", false
	before, after, found = strings.Cut("rightChara=cat.png", "=")
	fmt.Println(before, after, found) // "rightChara", "cat.png", true
}

strings 標準パッケージは便利機能が勢揃いでとても便利です。後半で詳しく解説します。また、経験者に特に覚えておいて欲しいのですが、Goでは基本的にこの strings パッケージを使って文字列を操作することが推奨されます。正規表現 regexp は相当複雑な操作をしたい時だけに限定して使うと良いでしょう。

とにかく、これで立ち絵とメッセージの切り替えができました!かなりノベルゲームの形に近づいてきましたね。

まとめ

  • 動きのない仮の画面を作った後は、どこを変数で置き換えればゲームの進行を表現できるか考える。
  • ノベルゲームなら、例えばシナリオをスライスで用意して、一個ずつ進めて立ち絵やメッセージを切り替える。
  • strings パッケージは文字列操作が一通り揃っているので便利。

これで、ノベルゲームの核は半分完成しました。細かなステップに分けて考えれば、ゲーム作りは難しくない、ということが伝われば嬉しいです!

残り半分の核は、クリックで進行する部分を修正することで達成できます。すぐにその解説に入ってもいいのですが、次からは一旦、データ管理を楽にしたり、お楽しみとして演出やサウンドの紹介を挟みます。好きなところから読んでいってください。

詳しい解説

文字列リテラル

Goでは文字列を表すのに2種類の囲み方があります。

a := "こんにちは\nこんにちは"
b := `こんにちは
こんにちは`

見た目とは裏腹に、a, b は同じ文字列データを表現しています。

前者はダブルクオートと呼ばれる記号で囲まれています。途中で改行できませんが、代わりに後述するエスケープ文字が使えるのが特徴で、この特徴により基本的にはこちらの方が多く使われます。

後者はバッククオートと呼ばれる記号で囲まれています。途中で改行できたり、見た目通りの文字列を表します。ただしバッククオート自体は文字列の中に含められません。ダブルクオートでは都合の悪い時や、改行を含む長文をそのままプログラム中に書きたいときに使います。

Goのエスケープ文字

文字列中で表現できないか表現しづらい特殊な文字を、バックスラッシュ \ から始まる文字の組で代わりに表すことができます。これをエスケープ文字と呼びます。

代表的なのは改行文字で、\n と書くとそれは改行を表します。前述の変数 a, b が同じ意味になるのはそういうわけです。他には、ダブルクオートを \" で、バックスラッシュそのものを \\ で表します。

注意すべきは日本語環境だとバックスラッシュと円記号がしばしば混ざってしまうことです。

文字 意味
¥ 円マーク
\ バックスラッシュ

上の文字二つをVSCodeにコピーしてみて、どう見えるか確認してみてください。もしバックスラッシュをコピーしたはずなのに円マークに見えている場合は、この二つの文字を見た目ではどうやっても区別できない問題が起きているので、フォントを変えるなどして対策した方が良いでしょう。

また、macOSでは、見た目の区別はつくがキーボードからバックスラッシュを入力すると勝手に円マークに置き換えられてしまう環境があります。もし上の文字をコピーしても問題ないのに自分で入力すると円マークになる場合は、Option (Alt) を押しながら入力してみてください。キーボードの設定を変更すれば、常にバックスラッシュを入力するようにもできます。

stringsパッケージ

stringsパッケージには文字列関係の操作が収録されています。よく使うのは多分このあたりでしょう。

  • strings.Split: 文字列を指定した文字(カンマとか、改行とか)で分割する。
  • strings.Fields: 空白文字(スペース、タブ、改行)で分割する。
  • strings.HasPrefix, strings.HasSuffix: 文字列が指定した文字列で始まる/終わるか調べる。Prefix/Suffixは接頭辞/接尾辞の意味。
  • strings.TrimPrefix, strings.TrimSuffix: 文字列の先頭/末尾から指定した文字列を取り除く。これかなりよく使います。
  • strings.TrimSpace: 文字列の先頭/末尾の空白文字(スペース、タブ、改行)を取り除く。文字列を綺麗にするイメージでこれもよく使います。
  • strings.Replace: 指定した文字列を別の文字列に置換する。

あとは、文字列をio.Readerに渡せるようにする strings.Reader 型、文字列を効率よく追記する strings.Builder 型などもあります。

strconvパッケージ

文字列とそれ以外の型との変換は strconv パッケージが担当します。

  • strconv.Atoi: 文字列を整数に変換する。AtoiはASCII to Integerの略。
  • strconv.ParseFloat: 文字列を浮動小数点数に変換する。

その他のパッケージ

その他にも文字列関係のパッケージはたくさんあります。画面への出力だけでなく「フォーマット文字列」を使って文字列を組み立てる fmt パッケージ、文字列を行ごとに読み込む時に便利な bufio パッケージ、正規表現を使って文字列を検索する regexp パッケージなどなど...。全部紹介していると日が暮れるので、興味があったらネット上の記事を探してみてください!

正規表現との使い分け

正規表現をすでに知っている方向けの説明なのですが、前述した通り基本的には strings パッケージを使い、regexp パッケージはよっぽど複雑な時に限定して使うべきです。というのもGoの正規表現は複雑で長大でも遅くならない代わりに、小さな文字列ではそんなに速くないからです。よくGoの正規表現は遅いと言われがちですが、「どんなケースでも致命的に遅くはならない」のを目指した結果こうなっていると捉えてください。そもそも小さいか単純な文字列なら strings パッケージが提供する関数でほとんど十分だと思うので、まずは strings パッケージを使ってください。

Unicode, UTF-8, rune型

「半角文字は1バイト、全角文字は2バイト」と思っている人がいたら、それはもう古い知識なので忘れましょう。Go, そして現代のほとんどのシステムで採用されているUTF-8では、ひらがな・漢字は3バイトです。その周辺のちょっと長い話をします。詳しくはGo公式ブログの記事(英語)をどうぞ。

「世界中の文字を一つの表にまとめようぜ!」というモチベーションで生まれた規格がUnicode(ユニコード)です。Unicodeにはアルファベット・記号・かな・漢字・絵文字に至るまで今や15万を超える文字が収録されています。Unicodeでは全ての文字に番号が割り振られており、コードポイント(符号位置)と呼びます。例えば「あ」には12354番(16進数で3042)が割り当てられています。

コードポイント(最大で111万4112)一個を表現するには3バイトか4バイト必要なのですが、これをいい感じにもっと少ないデータ量で表現できるのがUTF-8です。他にもデータ量を減らさずコードポイントそのまま保持するUTF-32や、世界中の文字が2バイトで表現できる(65535個で足りる)と思っていた頃の名残であるUTF-16や、そもそも世界ではなく日本の文字を表すための規格であるShift-JISなどもありますが、なんやかんやでUTF-8が一番優秀なのが認められてきたため、今日では世界のほとんど(インターネット上の98.5%)はUTF-8であるとのことです。ちなみにUTF-8を発明したメンバーの一人がGoを生み出したメンバーの一人でもあります。

文字化けとは、想定していた形式とは違う形式で文字データが解釈された時に起こる現象です。例えばUTF-8で「ああ」と書いたテキストをShift-JISで解釈すると「縺ゅ≠」になります。はい、今あなたにフィクションにおける文字化けを見て「この文字はUTF-8で書いてShift-JISで読んだんだな」と思ってしまう呪いをかけました。

UTF-8の知っておくべき特徴は、文字によってサイズが変わるということです。UTF-8では主要なアルファベットと記号(ASCII(アスキー)と呼びます)は1バイトになり、かな・漢字は3バイトになります。他にも2バイト、4バイトになる文字もあります。面倒そうですが、コンピューターにとっては実はすごく計算しやすい仕様になっており、それが他の方式を駆逐して広く採用される理由となっています。

そうすると日本語のテキストだと一見データ量が増えるようにも見えますが、それでもASCII(1バイトで表せる文字)の割合はそこそこ多いなど、諸々込みで考えるとそれでも少なくて済むみたいです。

Goでの事情

GoのプログラムはUTF-8で書くことが必須です。文字列リテラルも同じくよっぽど変わったことをしなければUTF-8です。当たり前のようで実はプログラミング言語によっては統一されてなくて文字化けしたり酷い目に遭ったりもするので、ありがたい話です。

GoではUnicodeコードポイントと同義のrune(ルーン)というかっこいい名前の型(int32 の別名)があります。1文字を ' (シングルクオート)で囲むとrune型になります。C言語には1文字が1バイトだと思っていた頃の char 型がありますが、それのGo版・Unicode版です。

var r rune = 'あ'
fmt.Println(r) // 12354

UTF-8は文字ごとにサイズが変わるので人間には扱いづらいですが、rune(32ビット、つまり4バイト固定)と相互に変換することで、扱いやすくなります。例えば range で文字列をループするとバイト位置とruneが得られます。

	for _, r := range "あいう" {
		fmt.Println(r)
	}
$ go run .
12354
12356
12358

rune関係の操作は unicode/utf8 パッケージにまとまっています。直接使うことはあまりないですが、知っておくといつか役に立つ日が来るかも。