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" }]]

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 読む

コードを読む。

Footnotes:

1

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