KDOC 57: sokoban-goを読む
DONE プロジェクトステータス
プロジェクトは終了である。
タイル - スプライト
タイルの上にあるものはスプライトとしているよう。1
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/loader/level.go#L23-L30
const ( exteriorSpriteNumber = 0 wallSpriteNumber = 1 floorSpriteNumber = 2 goalSpriteNumber = 3 boxSpriteNumber = 4 playerSpriteNumber = 5 )
タイルに対する操作
タイルに対する操作一覧。論理演算で判断しているな。Set関数では自身を書き換える。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/loader/level.go#L54-L84
// 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 }
読み込み
タイルはファイルから読み込んでいるようだ。よくわからない。タイルの縦×横の集合体がグリッドだ。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/loader/level.go#L165
grid := make([][]byte, len(lines))
コンポーネントを追加している↓。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/loader/level.go#L319-L327
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}, }) }
コンポーネントの定義
コンポーネントの種類の定義↓。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/loader/lib.go#L13-L19
type gameComponentList struct { GridElement *gc.GridElement Player *gc.Player Box *gc.Box Goal *gc.Goal Wall *gc.Wall }
保持している構造体↓。コンポーネントを入れる。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/components/lib.go#L5-L12
// 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のキーとして利用する。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/resources/controls.go#L3-L7
const ( / MoveUpAction is the action for moving up MoveUpAction = “MoveUp” / MoveUpFastAction is the action for moving up fast MoveUpFastAction = “MoveUpFast”
アクションを初期化している部分(ライブラリ)↓。TOMLファイルから読み取って設定するよう。
inputHandler.Actions = make(map[string]bool)
TOMLファイル。対応関係がわかりやすい。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/config/controls.toml#L1-L6
[controls.actions.MoveUp] combinations = [[{ key = "Up" }]] once = true [controls.actions.MoveUpFast] combinations = [[{ key = "ShiftLeft" }, { key = "Up" }], [{ key = "ShiftRight" }, { key = "Up" }]]
prefab
prefabが具体的にどういうことをするのかわからない。メニュー画面それぞれを保持しているぽい。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/resources/prefab.go#L5-L13
// 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コンポーネントを書き換えて表示する。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/states/pause_menu.go#L123-L137
// 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は配置する座標だな。なぜこの単語が使われているのかわからない。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/assets/metadata/entities/ui/main_menu.toml#L27-L34
[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というようだ。
メニューコンポーネントのマウスオーバーイベント
↓メニューコンポーネントそれぞれで、マウスが上にあるかを判定する。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/states/menu.go#L41-L46
// 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() {
↓コンポーネントのクエリ。レンダーできる、変形可能、マウスが反応可能、といった性質を持つものを対象にする。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/states/menu.go#L47
if world.Manager.Join(world.Components.Engine.SpriteRender, world.Components.Engine.Transform, world.Components.Engine.MouseReactive).Visit(
↓コンポーネントを特定して、stateの selection
(選択中の項目)を変える。クリックされていた場合は、遷移する。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/states/menu.go#L48-L59
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とは何か
↓グリッドを置き換えるシステムがある。グリッドは座標を持つことを示す。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/grid_transform.go#L16-L17
// GridTransformSystem sets transform for grid elements func GridTransformSystem(world w.World) {
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/grid_transform.go#L20
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に及ぼす、という感じか。
↓タイルの中からプレイヤー、箱を探す。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/grid_update.go#L18-L25
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を更新する。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/grid_update.go#L33-L47
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エンティティ。
↓箱の数、正しく配置されている箱の数をカウントする。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/info.go#L21-L29
for _, tile := range gameResources.Level.Grid.Data { if tile.Contains(resources.TileBox) { boxCount += 1
if tile.Contains(resources.TileGoal) { boxOnGoalCount += 1 } } }
↓テキストコンポーネントを更新する。IDで分岐する。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/info.go#L31-L51
// 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 {
↓ゲームリソース。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/resources/game.go#L128-L135
// Game contains game resources type Game struct { StateEvent StateEvent Package PackageData Level Level GridLayout GridLayout SaveConfig SaveConfig }
- StateEvent
- 完了したかどうか
- PackageData
- 読み込んだPackageのデータ。Packageはステージのセット
- Level
- 現在の階層(難易度)。階層数、移動履歴、グリッド情報を持つ
タイル
↓タイルの状態一覧。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/resources/game.go#L105-L112
// List of game tiles const ( TilePlayer = gloader.TilePlayer TileBox = gloader.TileBox TileGoal = gloader.TileGoal TileWall = gloader.TileWall TileEmpty = gloader.TileEmpty )
stateとsystemの関係
stateによって適用systemが異なる。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/states/gameplay.go#L65-L72
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がセットされてるはず。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/move.go#L9-L11
// MoveSystem moves player func MoveSystem(world w.World) { moveUpAction := world.Resources.InputHandler.Actions[resources.MoveUpAction]
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/move.go#L21-L23
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にセットする。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/states/level_complete_menu.go#L121-L123
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) || inpututil.IsKeyJustPressed(ebiten.KeySpace) { world.Resources.InputHandler.Actions[resources.RestartAction] = true }
↓そのあとsystemで処理する。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/switch_level.go#L17
restartAction := world.Resources.InputHandler.Actions[resources.RestartAction]
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/systems/switch_level.go#L25-L28
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箇所で管理できる。直接それぞれの箇所でキーボード押下を検知するよりも見通しやすい。キー検知は具体的すぎるコードだ。
メニューの抽象化
↓複数あるメニューは、このように抽象化されている。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/lib/states/menu.go#L16-L22
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を実行している。どのステートでも描画する
https://github.com/x-hgg-x/goecsengine/blob/be27b724d4c8f46b9d31959fe91c4ecf188429ea/states/lib.go#L98-L103
/ 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の抜粋。
https://github.com/x-hgg-x/goecsengine/blob/be27b724d4c8f46b9d31959fe91c4ecf188429ea/systems/sprite/render.go#L21-L25
/ 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がどういう感じになっているか
ステートのスタック構造を持っているが、これはどういう感じで推移するか。
- おそらくスタック構造があるために、メニュー画面を出したあとに元のステートに戻れる
- 貯まり続けないことを保証するには
スプライトを動的に追加する
真っ黒な画像をスプライト画像として登録して、フェードアウトとしている。スプライトをファイルで読み込むほかに、こういったこともできる。
https://github.com/x-hgg-x/sokoban-go/blob/e9d204aeebe393d730fb4bdcb060d249f1470485/main.go#L73-L75
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を操作している。
Footnotes:
ゲームづくりの定石知識が足りてない。