KDOC 57: sokoban-goを読む

DONE プロジェクトのステータス

プロジェクトは終了である。

概要

gameを作るけど最後までうまくいった試しがない。人のコードを見る。

  • タイル空間の定石を探る
    • 移動
  • コンポーネントシステムの定石を探る

タイル - スプライト

タイルの上にあるものはスプライトとしているよう。1

const (
	exteriorSpriteNumber = 0
	wallSpriteNumber     = 1
	floorSpriteNumber    = 2
	goalSpriteNumber     = 3
	boxSpriteNumber      = 4
	playerSpriteNumber   = 5
)

タイルに対する操作

タイルに対する操作一覧。論理演算で判断しているな。Set関数では自身を書き換える。

// Tile is a game tile
type Tile uint8

// List of game tiles
const (
	TilePlayer Tile = 1 << iota
	TileBox
	TileGoal
	TileWall
	TileEmpty Tile = 0
)

// Contains checks if a game tile contains the provided tile
func (t *Tile) Contains(other Tile) bool {
	return (*t & other) == other
}

// ContainsAny checks if a game tile contains any of the provided tiles
func (t *Tile) ContainsAny(other Tile) bool {
	return (*t & other) != 0
}

// Set adds the provided tile to a game tile
func (t *Tile) Set(other Tile) {
	*t |= other
}

// Remove removes the provided tile to a game tile
func (t *Tile) Remove(other Tile) {
	*t &= 0xFF ^ other
}

読み込み

タイルはファイルから読み込んでいるようだ。よくわからない。タイルの縦×横の集合体がグリッドだ。

grid := make([][]byte, len(lines))

コンポーネントを追加している↓。

func createFloorEntity(componentList *loader.EntityComponentList, gameSpriteSheet *ec.SpriteSheet, line, col int) {
	componentList.Engine = append(componentList.Engine, loader.EngineComponentList{
		SpriteRender: &ec.SpriteRender{SpriteSheet: gameSpriteSheet, SpriteNumber: floorSpriteNumber},
		Transform:    &ec.Transform{},
	})
	componentList.Game = append(componentList.Game, gameComponentList{
		GridElement: &gc.GridElement{Line: line, Col: col},
	})
}

コンポーネントの定義

コンポーネントの種類の定義↓。

type gameComponentList struct {
	GridElement *gc.GridElement
	Player      *gc.Player
	Box         *gc.Box
	Goal        *gc.Goal
	Wall        *gc.Wall
}

保持している構造体↓。コンポーネントを入れる。

// Components contains references to all game components
type Components struct {
	GridElement *ecs.SliceComponent
	Player      *ecs.NullComponent
	Box         *ecs.NullComponent
	Goal        *ecs.NullComponent
	Wall        *ecs.NullComponent
}

resources

アクションが定数として表現されている。アクションmapのキーとして利用する。

const (
	// MoveUpAction is the action for moving up
	MoveUpAction = "MoveUp"
	// MoveUpFastAction is the action for moving up fast
	MoveUpFastAction = "MoveUpFast"

アクションを初期化している部分(ライブラリ)↓。TOMLファイルから読み取って設定するよう。

TOMLファイル。対応関係がわかりやすい。

[controls.actions.MoveUp]
combinations = [[{ key = "Up" }]]
once = true

[controls.actions.MoveUpFast]
combinations = [[{ key = "ShiftLeft" }, { key = "Up" }], [{ key = "ShiftRight" }, { key = "Up" }]]
[controls.actions.MoveUp]
combinations = [[{ key = "Up" }]]
once = true

[controls.actions.MoveUpFast]
combinations = [[{ key = "ShiftLeft" }, { key = "Up" }], [{ key = "ShiftRight" }, { key = "Up" }]]

prefab

prefabが具体的にどういうことをするのかわからない。メニュー画面それぞれを保持しているぽい。

// MenuPrefabs contains menu prefabs
type MenuPrefabs struct {
	MainMenu          loader.EntityComponentList
	ChoosePackageMenu loader.EntityComponentList
	PauseMenu         loader.EntityComponentList
	LevelCompleteMenu loader.EntityComponentList
	HighscoresMenu    loader.EntityComponentList
	SolutionsMenu     loader.EntityComponentList
}

UI更新

UIもコンポーネントである。↓UIコンポーネントを書き換えて表示する。

// Update text components
world.Manager.Join(world.Components.Engine.Text, world.Components.Engine.UITransform).Visit(ecs.Visit(func(entity ecs.Entity) {
	text := world.Components.Engine.Text.Get(entity).(*ec.Text)

	switch text.ID {
	case "view_highscore":
		if st.invalidHighscore {
			text.Color = color.RGBA{0, 0, 0, 120}
		}
	case "view_solution":
		if st.invalidSolution {
			text.Color = color.RGBA{0, 0, 0, 120}
		}
	}
}))

↓コンポーネントはTOMLで定義されているようだ。各メニューごとにファイルがあるな。translationは配置する座標だな。なぜこの単語が使われているのかわからない。

[entity.components.Text]
id = "cursor_view_highscores"
text = "\u25ba"
font_face = { font = "hack", options.size = 60.0 }
color = [255, 255, 255, 255]

[entity.components.UITransform]
translation = { x = 40, y = 400 }

画像を移動や拡大縮小など変化させるのをtransformというようだ。

メニューコンポーネントのマウスオーバーイベント

↓メニューコンポーネントそれぞれで、マウスが上にあるかを判定する。

// Handle mouse events only if mouse is moved or clicked
x, y := ebiten.CursorPosition()
if x != menuLastCursorPosition.X || y != menuLastCursorPosition.Y || inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
	menuLastCursorPosition = m.VectorInt2{X: x, Y: y}

	for iElem, id := range menu.getMenuIDs() {

↓コンポーネントのクエリ。レンダーできる、変形可能、マウスが反応可能、といった性質を持つものを対象にする。

if world.Manager.Join(world.Components.Engine.SpriteRender, world.Components.Engine.Transform, world.Components.Engine.MouseReactive).Visit(

↓コンポーネントを特定して、stateの selection (選択中の項目)を変える。クリックされていた場合は、遷移する。

func(index int) (skip bool) {
	mouseReactive := world.Components.Engine.MouseReactive.Get(ecs.Entity(index)).(*ec.MouseReactive)
	if mouseReactive.ID == id && mouseReactive.Hovered {
		menu.setSelection(iElem)
		if mouseReactive.JustClicked {
			transition = menu.confirmSelection(world)
			return true
		}
	}
	return false
}) {
return transition

GridElementとは何か

↓グリッドを置き換えるシステムがある。グリッドは座標を持つことを示す。

// GridTransformSystem sets transform for grid elements
func GridTransformSystem(world w.World) {
world.Manager.Join(gameComponents.GridElement, world.Components.Engine.SpriteRender, world.Components.Engine.Transform).Visit(ecs.Visit(func(entity ecs.Entity) {
  • GridElement – 座標を持つことを示す
  • SpriteRender – 描画可能なことを示す
  • Transform – 何かわからない
    • 壁、箱、プレイヤー、UI…など描画されるものについている
    • 画像変換か

このシステムはタイルの変化をEntityに及ぼす、という感じか。

↓タイルの中からプレイヤー、箱を探す。

for iTile, tile := range gameResources.Level.Grid.Data {
	switch {
	case tile.Contains(resources.TilePlayer):
		playerIndex = iTile
	case tile.Contains(resources.TileBox):
		boxIndices = append(boxIndices, iTile)
	}
}

プレイヤーコンポーネント、箱コンポーネントのgridElementを更新する。

world.Manager.Join(gameComponents.GridElement).Visit(ecs.Visit(func(entity ecs.Entity) {
	switch {
	case entity.HasComponent(gameComponents.Player):
		gridElement := gameComponents.GridElement.Get(entity).(*gc.GridElement)
		gridElement.Line = paddingRow + playerIndex/levelWidth
		gridElement.Col = paddingCol + playerIndex%levelWidth

	case entity.HasComponent(gameComponents.Box):
		gridElement := gameComponents.GridElement.Get(entity).(*gc.GridElement)
		boxIndex := boxIndices[0]
		boxIndices = boxIndices[1:]
		gridElement.Line = paddingRow + boxIndex/levelWidth
		gridElement.Col = paddingCol + boxIndex%levelWidth
	}
}))

InfoSystemとは何か

GridElementと同様に、タイルの状態をエンティティに反映する。今回はUIエンティティ。

↓箱の数、正しく配置されている箱の数をカウントする。

for _, tile := range gameResources.Level.Grid.Data {
	if tile.Contains(resources.TileBox) {
		boxCount += 1

		if tile.Contains(resources.TileGoal) {
			boxOnGoalCount += 1
		}
	}
}

↓テキストコンポーネントを更新する。IDで分岐する。

// Set text info
world.Manager.Join(world.Components.Engine.Text, world.Components.Engine.UITransform).Visit(ecs.Visit(func(entity ecs.Entity) {
	text := world.Components.Engine.Text.Get(entity).(*ec.Text)

	switch text.ID {
	case "level":
		text.Text = fmt.Sprintf("LEVEL %d/%d", gameResources.Level.CurrentNum+1, len(gameResources.Package.Levels))
		if !solutionMode && gameResources.Level.Modified {
			text.Text += "(*)"
		}
	case "box":
		text.Text = fmt.Sprintf("BOX: %d/%d", boxOnGoalCount, boxCount)
	case "step":
		text.Text = fmt.Sprintf("STEPS: %d", len(gameResources.Level.Movements))
	case "package":
		text.Text = fmt.Sprintf("Package: %s", gameResources.Package.Name)
		if solutionMode {
			text.Text += " - Replaying solution..."
		}
	}
}))

Resourceとは何か

ECS用語におけるリソースとは何か。エンティティに関係ないデータのこと。マップデータとかかな。

// Resources contains references to data not related to any entity
type Resources struct {

↓ゲームリソース。

// Game contains game resources
type Game struct {
	StateEvent StateEvent
	Package    PackageData
	Level      Level
	GridLayout GridLayout
	SaveConfig SaveConfig
}
StateEvent
完了したかどうか
PackageData
読み込んだPackageのデータ。Packageはステージのセット
Level
現在の階層(難易度)。階層数、移動履歴、グリッド情報を持つ

タイル

↓タイルの状態一覧。

// List of game tiles
const (
	TilePlayer = gloader.TilePlayer
	TileBox    = gloader.TileBox
	TileGoal   = gloader.TileGoal
	TileWall   = gloader.TileWall
	TileEmpty  = gloader.TileEmpty
)

stateとsystemの関係

stateによって適用systemが異なる。

func (st *GameplayState) Update(world w.World) states.Transition {
	g.SwitchLevelSystem(world)
	g.UndoSystem(world)
	g.MoveSystem(world)
	g.SaveSystem(world)
	g.InfoSystem(world, false)
	g.GridUpdateSystem(world)
	g.GridTransformSystem(world)

移動はどうやっているか

↓systemではこうしている。シンプルにリソースの値に応じてMove()を呼んでいる。ボタン押下に応じて、Actionsがセットされてるはず。

// MoveSystem moves player
func MoveSystem(world w.World) {
	moveUpAction := world.Resources.InputHandler.Actions[resources.MoveUpAction]
switch {
case moveUpAction || moveUpFastAction:
	resources.Move(world, resources.MovementUp)

↓Actionsの中身は、アクション文字列とboolのマップである。

// InputHandler contains input axis values and actions corresponding to specified controls
type InputHandler struct {
	// Axes contains input axis values
	Axes map[string]float64
	// Actions contains input actions
	Actions map[string]bool
}

↓このように、キーボード押下時Actionsにセットする。

if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) {
	world.Resources.InputHandler.Actions[resources.RestartAction] = true
}

↓そのあとsystemで処理する。

restartAction := world.Resources.InputHandler.Actions[resources.RestartAction]
case restartAction:
	gameResources.Level.Movements = []resources.MovementType{}
	gameResources.Level.Modified = true
	newLevel = gameResources.Level.CurrentNum

InputHandlerのリセットはどこでやっているか

world.Resources.InputHandlerはさまざまなところで使われている。これはボタンの押下状態に応じて値が変わるように見える。リセットが必要だが、どこでやっているか。

↓ECSライブラリのなかでやっている。

for k, v := range world.Resources.Controls.Actions {
	world.Resources.InputHandler.Actions[k] = isActionDone(v)
}

↑InputSyste関数は、StateMachineのUpdateで呼ばれる。なので、毎回リセットされているのだろう。

この設計にすることで、キーボード押下を1箇所で管理できる。直接それぞれの箇所でキーボード押下を検知するよりも見通しやすい。キー検知は具体的すぎるコードだ。

メニューの抽象化

↓複数あるメニューは、このように抽象化されている。

type menu interface {
	getSelection() int
	setSelection(selection int)
	confirmSelection(world w.World) states.Transition
	getMenuIDs() []string
	getCursorMenuIDs() []string
}

どうやって描画しているか

  • 読み込み時にコンポーネントを初期化する
    • 初期化 = Prefabをセットする
    • tomlから読み込む
    • 主なコンポーネントはtext。idやtextなどを持つ
    • あるいはマウスに反応するコンポーネントもある
  • 各stateでsystemを実行し、コンポーネントに変更を加える
  • stateMachineの内でSystemを実行している。どのステートでも描画する
// Draw draws the screen after a state update
func (sm *StateMachine) Draw(world w.World, screen *ebiten.Image) {
	// Run drawing systems
	s.RenderSpriteSystem(world, screen)
	u.RenderUISystem(world, screen)
}

↓呼び出されているRenderSpriteSystemの抜粋。

// RenderSpriteSystem draws images.
// Images are drawn in ascending order of depth.
// Images with higher depth are thus drawn above images with lower depth.
func RenderSpriteSystem(world w.World, screen *ebiten.Image) {
	sprites := world.Manager.Join(world.Components.Engine.SpriteRender, world.Components.Engine.Transform)

StateMachineがどういう感じになっているか

ステートのスタック構造を持っているが、これはどういう感じで推移するか。

  • おそらくスタック構造があるために、メニュー画面を出したあとに元のステートに戻れる
  • 貯まり続けないことを保証するには

スプライトを動的に追加する

真っ黒な画像をスプライト画像として登録して、フェードアウトとしている。スプライトをファイルで読み込むほかに、こういったこともできる。

textureImage := ebiten.NewImage(minGameWidth, minGameHeight)
textureImage.Fill(color.RGBA{A: 120})
spriteSheets["fadeOut"] = ec.SpriteSheet{Texture: ec.Texture{Image: textureImage}, Sprites: []ec.Sprite{{Width: minGameWidth, Height: minGameHeight}}}

stateとentity

stateごとでファイルからentityを生成し直している。そこから、systemでいろいろentityを操作している。

Tasks

なし。

Archives

DONE 読む

コードを読む。

関連

Backlinks

Footnotes:

1

ゲームづくりの定石知識が足りてない。