From 33ec0b1d868a60b2dbad06a14d4a6b43700d1ecf Mon Sep 17 00:00:00 2001 From: Trevor Slocum Date: Thu, 18 Nov 2021 20:13:28 -0800 Subject: [PATCH] Initial commit --- LICENSE | 21 ++++ README.md | 16 +++ component.go | 67 +++++++++++ doc.go | 4 + entity.go | 13 +++ examples/twinstick/asset/asset.go | 8 ++ examples/twinstick/component/bullet.go | 17 +++ examples/twinstick/component/position.go | 18 +++ examples/twinstick/component/velocity.go | 18 +++ examples/twinstick/component/weapon.go | 27 +++++ examples/twinstick/entity/bullet.go | 27 +++++ examples/twinstick/entity/player.go | 36 ++++++ examples/twinstick/game/game.go | 113 +++++++++++++++++++ examples/twinstick/main.go | 45 ++++++++ examples/twinstick/system/draw_bullets.go | 43 ++++++++ examples/twinstick/system/draw_player.go | 40 +++++++ examples/twinstick/system/input_fire.go | 90 +++++++++++++++ examples/twinstick/system/input_move.go | 57 ++++++++++ examples/twinstick/system/movement.go | 59 ++++++++++ examples/twinstick/system/printinfo.go | 43 ++++++++ go.mod | 15 +++ go.sum | 66 +++++++++++ gohan.go | 129 ++++++++++++++++++++++ system.go | 33 ++++++ 24 files changed, 1005 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 component.go create mode 100644 doc.go create mode 100644 entity.go create mode 100644 examples/twinstick/asset/asset.go create mode 100644 examples/twinstick/component/bullet.go create mode 100644 examples/twinstick/component/position.go create mode 100644 examples/twinstick/component/velocity.go create mode 100644 examples/twinstick/component/weapon.go create mode 100644 examples/twinstick/entity/bullet.go create mode 100644 examples/twinstick/entity/player.go create mode 100644 examples/twinstick/game/game.go create mode 100644 examples/twinstick/main.go create mode 100644 examples/twinstick/system/draw_bullets.go create mode 100644 examples/twinstick/system/draw_player.go create mode 100644 examples/twinstick/system/input_fire.go create mode 100644 examples/twinstick/system/input_move.go create mode 100644 examples/twinstick/system/movement.go create mode 100644 examples/twinstick/system/printinfo.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 gohan.go create mode 100644 system.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51c4dd1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Trevor Slocum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a138107 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# gohan +[![GoDoc](https://code.rocketnine.space/tslocum/godoc-static/raw/branch/master/badge.svg)](https://docs.rocketnine.space/code.rocketnine.space/tslocum/gohan) +[![Donate](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space) + +[Entity component system](https://en.wikipedia.org/wiki/Entity_component_system) framework for [Ebiten](https://ebiten.org) + +**Note:** This framework is still in development. Breaking changes may be +made until v1.0 is released. + +## Documentation + +Documentation is available via [godoc](https://docs.rocketnine.space/code.rocketnine.space/tslocum/gohan). + +## Support + +Please share issues and suggestions [here](https://code.rocketnine.space/tslocum/gohan/issues). diff --git a/component.go b/component.go new file mode 100644 index 0000000..8bd7070 --- /dev/null +++ b/component.go @@ -0,0 +1,67 @@ +package gohan + +import "fmt" + +// ComponentID is a component identifier. Each Component is assigned a unique ID +// via NextComponentID, and implements a ComponentID method which returns the ID. +type ComponentID int + +// Component represents data for an entity, and how it interacts with the world. +type Component interface { + ComponentID() ComponentID +} + +var nextComponentID ComponentID + +// NextComponentID returns the next available ComponentID. +func NextComponentID() ComponentID { + id := nextComponentID + nextComponentID++ + return id +} + +func (entity EntityID) propagateChanges() { + for i, system := range gameSystems { + systemEntityIndex := -1 + for j, systemEntity := range gameSystemEntities[i] { + if systemEntity == entity { + systemEntityIndex = j + break + } + } + + if system.Matches(entity) { + if systemEntityIndex != -1 { + // Already attached. + continue + } + + gameSystemEntities[i] = append(gameSystemEntities[i], entity) + print(fmt.Sprintf("Attached entity %d to system %d.", entity, i)) + } else if systemEntityIndex != -1 { + // Detach from system. + gameSystemEntities[i] = append(gameSystemEntities[i][:systemEntityIndex], gameSystemEntities[i][systemEntityIndex+1:]...) + } + } +} + +// AddComponent adds a Component to an Entity. +func (entity EntityID) AddComponent(component Component) { + componentID := component.ComponentID() + + if gameComponents[entity] == nil { + gameComponents[entity] = make(map[ComponentID]interface{}) + } + gameComponents[entity][componentID] = component + + entity.propagateChanges() +} + +// Component gets a Component of an Entity. +func (entity EntityID) Component(componentID ComponentID) interface{} { + components := gameComponents[entity] + if components == nil { + return nil + } + return components[componentID] +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..0dd60f3 --- /dev/null +++ b/doc.go @@ -0,0 +1,4 @@ +/* +Package gohan provides an Entity Component System framework for Ebiten. +*/ +package gohan diff --git a/entity.go b/entity.go new file mode 100644 index 0000000..b0ff94d --- /dev/null +++ b/entity.go @@ -0,0 +1,13 @@ +package gohan + +// EntityID is an entity identifier. +type EntityID int + +var nextEntityID EntityID + +// NextEntityID returns the next available EntityID. +func NextEntityID() EntityID { + entityID := nextEntityID + nextEntityID++ + return entityID +} diff --git a/examples/twinstick/asset/asset.go b/examples/twinstick/asset/asset.go new file mode 100644 index 0000000..200da16 --- /dev/null +++ b/examples/twinstick/asset/asset.go @@ -0,0 +1,8 @@ +//go:build example +// +build example + +package asset + +import "github.com/hajimehoshi/ebiten/v2" + +var ImgWhiteSquare = ebiten.NewImage(32, 32) diff --git a/examples/twinstick/component/bullet.go b/examples/twinstick/component/bullet.go new file mode 100644 index 0000000..eefd9b2 --- /dev/null +++ b/examples/twinstick/component/bullet.go @@ -0,0 +1,17 @@ +//go:build example +// +build example + +package component + +import ( + "code.rocketnine.space/tslocum/gohan" +) + +type BulletComponent struct { +} + +var BulletComponentID = gohan.NextComponentID() + +func (p *BulletComponent) ComponentID() gohan.ComponentID { + return BulletComponentID +} diff --git a/examples/twinstick/component/position.go b/examples/twinstick/component/position.go new file mode 100644 index 0000000..646a755 --- /dev/null +++ b/examples/twinstick/component/position.go @@ -0,0 +1,18 @@ +//go:build example +// +build example + +package component + +import ( + "code.rocketnine.space/tslocum/gohan" +) + +type PositionComponent struct { + X, Y float64 +} + +var PositionComponentID = gohan.NextComponentID() + +func (p *PositionComponent) ComponentID() gohan.ComponentID { + return PositionComponentID +} diff --git a/examples/twinstick/component/velocity.go b/examples/twinstick/component/velocity.go new file mode 100644 index 0000000..1c98b48 --- /dev/null +++ b/examples/twinstick/component/velocity.go @@ -0,0 +1,18 @@ +//go:build example +// +build example + +package component + +import ( + "code.rocketnine.space/tslocum/gohan" +) + +type VelocityComponent struct { + X, Y float64 +} + +var VelocityComponentID = gohan.NextComponentID() + +func (c *VelocityComponent) ComponentID() gohan.ComponentID { + return VelocityComponentID +} diff --git a/examples/twinstick/component/weapon.go b/examples/twinstick/component/weapon.go new file mode 100644 index 0000000..b2f4a51 --- /dev/null +++ b/examples/twinstick/component/weapon.go @@ -0,0 +1,27 @@ +//go:build example +// +build example + +package component + +import ( + "time" + + "code.rocketnine.space/tslocum/gohan" +) + +type WeaponComponent struct { + Ammo int + + Damage int + + FireRate time.Duration + LastFire time.Time + + BulletSpeed float64 +} + +var WeaponComponentID = gohan.NextComponentID() + +func (p *WeaponComponent) ComponentID() gohan.ComponentID { + return WeaponComponentID +} diff --git a/examples/twinstick/entity/bullet.go b/examples/twinstick/entity/bullet.go new file mode 100644 index 0000000..54f8b50 --- /dev/null +++ b/examples/twinstick/entity/bullet.go @@ -0,0 +1,27 @@ +//go:build example +// +build example + +package entity + +import ( + "code.rocketnine.space/tslocum/gohan" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/component" +) + +func NewBullet(x, y, xSpeed, ySpeed float64) gohan.EntityID { + bullet := gohan.NextEntityID() + + bullet.AddComponent(&component.PositionComponent{ + X: x, + Y: y, + }) + + bullet.AddComponent(&component.VelocityComponent{ + X: xSpeed, + Y: ySpeed, + }) + + bullet.AddComponent(&component.BulletComponent{}) + + return bullet +} diff --git a/examples/twinstick/entity/player.go b/examples/twinstick/entity/player.go new file mode 100644 index 0000000..c947391 --- /dev/null +++ b/examples/twinstick/entity/player.go @@ -0,0 +1,36 @@ +//go:build example +// +build example + +package entity + +import ( + "math" + "time" + + "code.rocketnine.space/tslocum/gohan" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/component" +) + +func NewPlayer() gohan.EntityID { + player := gohan.NextEntityID() + + // Set position to -1,-1 to indicate the player has not been assigned a + // position yet. We will place the player in the center of the screen when + // we receive the screen dimensions for the first time. + player.AddComponent(&component.PositionComponent{ + X: -1, + Y: -1, + }) + + player.AddComponent(&component.VelocityComponent{}) + + weapon := &component.WeaponComponent{ + Ammo: math.MaxInt64, + Damage: 1, + FireRate: 100 * time.Millisecond, + BulletSpeed: 15, + } + player.AddComponent(weapon) + + return player +} diff --git a/examples/twinstick/game/game.go b/examples/twinstick/game/game.go new file mode 100644 index 0000000..049588d --- /dev/null +++ b/examples/twinstick/game/game.go @@ -0,0 +1,113 @@ +//go:build example +// +build example + +package game + +import ( + "image/color" + "os" + "sync" + + "code.rocketnine.space/tslocum/gohan" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/asset" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/component" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/entity" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/system" + "github.com/hajimehoshi/ebiten/v2" +) + +// game is an isometric demo game. +type game struct { + w, h int + + player gohan.EntityID + + op *ebiten.DrawImageOptions + + disableEsc bool + + debugMode bool + cpuProfile *os.File + + movementSystem *system.MovementSystem + + sync.Mutex +} + +// NewGame returns a new isometric demo game. +func NewGame() (*game, error) { + g := &game{ + op: &ebiten.DrawImageOptions{}, + } + + g.addSystems() + + err := g.loadAssets() + if err != nil { + return nil, err + } + + g.player = entity.NewPlayer() + + asset.ImgWhiteSquare.Fill(color.White) + + return g, nil +} + +// Layout is called when the game's layout changes. +func (g *game) Layout(outsideWidth, outsideHeight int) (int, int) { + s := ebiten.DeviceScaleFactor() + w, h := int(s*float64(outsideWidth)), int(s*float64(outsideHeight)) + if w != g.w || h != g.h { + g.w, g.h = w, h + g.movementSystem.ScreenW, g.movementSystem.ScreenH = float64(w), float64(h) + + position := g.player.Component(component.PositionComponentID).(*component.PositionComponent) + if position.X == -1 && position.Y == -1 { + position.X, position.Y = float64(g.w)/2-16, float64(g.h)/2-16 + } + } + return g.w, g.h +} + +func (g *game) Update() error { + if ebiten.IsWindowBeingClosed() { + g.Exit() + return nil + } + + return gohan.Update() +} + +func (g *game) Draw(screen *ebiten.Image) { + err := gohan.Draw(screen) + if err != nil { + panic(err) + } +} + +func (g *game) addSystems() { + gohan.AddSystem(system.NewMovementInputSystem(g.player)) + + g.movementSystem = &system.MovementSystem{} + gohan.AddSystem(g.movementSystem) + + gohan.AddSystem(system.NewFireInputSystem(g.player)) + + renderBullet := system.NewDrawBulletsSystem() + gohan.AddSystem(renderBullet) + + renderPlayer := system.NewDrawPlayerSystem(g.player) + gohan.AddSystem(renderPlayer) + + printInfo := system.NewPrintInfoSystem(g.player) + gohan.AddSystem(printInfo) +} + +func (g *game) loadAssets() error { + return nil +} + +func (g *game) Exit() { + os.Exit(0) +} diff --git a/examples/twinstick/main.go b/examples/twinstick/main.go new file mode 100644 index 0000000..6accdfc --- /dev/null +++ b/examples/twinstick/main.go @@ -0,0 +1,45 @@ +//go:build example +// +build example + +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/game" + "github.com/hajimehoshi/ebiten/v2" +) + +func main() { + ebiten.SetWindowTitle("Twin-Stick Shooter Example - Gohan") + ebiten.SetWindowResizable(true) + ebiten.SetFullscreen(true) + ebiten.SetMaxTPS(144) + ebiten.SetRunnableOnUnfocused(true) + ebiten.SetWindowClosingHandled(true) + ebiten.SetFPSMode(ebiten.FPSModeVsyncOn) + ebiten.SetCursorShape(ebiten.CursorShapeCrosshair) + + g, err := game.NewGame() + if err != nil { + log.Fatal(err) + } + + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, + syscall.SIGINT, + syscall.SIGTERM) + go func() { + <-sigc + + g.Exit() + }() + + err = ebiten.RunGame(g) + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/twinstick/system/draw_bullets.go b/examples/twinstick/system/draw_bullets.go new file mode 100644 index 0000000..b12c12d --- /dev/null +++ b/examples/twinstick/system/draw_bullets.go @@ -0,0 +1,43 @@ +//go:build example +// +build example + +package system + +import ( + "code.rocketnine.space/tslocum/gohan" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/asset" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/component" + "github.com/hajimehoshi/ebiten/v2" +) + +type DrawBulletsSystem struct { + op *ebiten.DrawImageOptions +} + +func NewDrawBulletsSystem() *DrawBulletsSystem { + return &DrawBulletsSystem{ + op: &ebiten.DrawImageOptions{}, + } +} + +func (s *DrawBulletsSystem) Matches(entity gohan.EntityID) bool { + position := entity.Component(component.PositionComponentID) + bullet := entity.Component(component.BulletComponentID) + + return position != nil && bullet != nil +} + +func (s *DrawBulletsSystem) Update(_ gohan.EntityID) error { + return gohan.ErrSystemWithoutUpdate +} + +func (s *DrawBulletsSystem) Draw(entity gohan.EntityID, screen *ebiten.Image) error { + position := entity.Component(component.PositionComponentID).(*component.PositionComponent) + + s.op.GeoM.Reset() + s.op.GeoM.Translate(-16, -16) + s.op.GeoM.Scale(0.5, 0.5) + s.op.GeoM.Translate(position.X, position.Y) + screen.DrawImage(asset.ImgWhiteSquare, s.op) + return nil +} diff --git a/examples/twinstick/system/draw_player.go b/examples/twinstick/system/draw_player.go new file mode 100644 index 0000000..1bbbbfc --- /dev/null +++ b/examples/twinstick/system/draw_player.go @@ -0,0 +1,40 @@ +//go:build example +// +build example + +package system + +import ( + "code.rocketnine.space/tslocum/gohan" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/asset" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/component" + "github.com/hajimehoshi/ebiten/v2" +) + +type drawPlayerSystem struct { + player gohan.EntityID + op *ebiten.DrawImageOptions +} + +func NewDrawPlayerSystem(player gohan.EntityID) *drawPlayerSystem { + return &drawPlayerSystem{ + player: player, + op: &ebiten.DrawImageOptions{}, + } +} + +func (s *drawPlayerSystem) Matches(entity gohan.EntityID) bool { + return entity == s.player +} + +func (s *drawPlayerSystem) Update(_ gohan.EntityID) error { + return gohan.ErrSystemWithoutUpdate +} + +func (s *drawPlayerSystem) Draw(entity gohan.EntityID, screen *ebiten.Image) error { + position := entity.Component(component.PositionComponentID).(*component.PositionComponent) + + s.op.GeoM.Reset() + s.op.GeoM.Translate(position.X-16, position.Y-16) + screen.DrawImage(asset.ImgWhiteSquare, s.op) + return nil +} diff --git a/examples/twinstick/system/input_fire.go b/examples/twinstick/system/input_fire.go new file mode 100644 index 0000000..12a7142 --- /dev/null +++ b/examples/twinstick/system/input_fire.go @@ -0,0 +1,90 @@ +//go:build example +// +build example + +package system + +import ( + "math" + "time" + + "code.rocketnine.space/tslocum/gohan" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/component" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/entity" + "github.com/hajimehoshi/ebiten/v2" +) + +func angle(x1, y1, x2, y2 float64) float64 { + return math.Atan2(y1-y2, x1-x2) +} + +type fireInputSystem struct { + player gohan.EntityID +} + +func NewFireInputSystem(player gohan.EntityID) *fireInputSystem { + return &fireInputSystem{ + player: player, + } +} + +func (_ *fireInputSystem) Matches(e gohan.EntityID) bool { + weapon := e.Component(component.WeaponComponentID) + + return weapon != nil +} + +func (s *fireInputSystem) fire(weapon *component.WeaponComponent, position *component.PositionComponent, fireAngle float64) { + if time.Since(weapon.LastFire) < weapon.FireRate { + return + } + + weapon.Ammo-- + weapon.LastFire = time.Now() + + speedX := math.Cos(fireAngle) * -weapon.BulletSpeed + speedY := math.Sin(fireAngle) * -weapon.BulletSpeed + + bullet := entity.NewBullet(position.X, position.Y, speedX, speedY) + _ = bullet +} + +func (s *fireInputSystem) Update(_ gohan.EntityID) error { + weapon := s.player.Component(component.WeaponComponentID).(*component.WeaponComponent) + + if weapon.Ammo <= 0 { + return nil + } + + position := s.player.Component(component.PositionComponentID).(*component.PositionComponent) + + if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) { + cursorX, cursorY := ebiten.CursorPosition() + fireAngle := angle(position.X, position.Y, float64(cursorX), float64(cursorY)) + s.fire(weapon, position, fireAngle) + } + + switch { + case ebiten.IsKeyPressed(ebiten.KeyLeft) && ebiten.IsKeyPressed(ebiten.KeyUp): + s.fire(weapon, position, math.Pi/4) + case ebiten.IsKeyPressed(ebiten.KeyLeft) && ebiten.IsKeyPressed(ebiten.KeyDown): + s.fire(weapon, position, -math.Pi/4) + case ebiten.IsKeyPressed(ebiten.KeyRight) && ebiten.IsKeyPressed(ebiten.KeyUp): + s.fire(weapon, position, math.Pi*.75) + case ebiten.IsKeyPressed(ebiten.KeyRight) && ebiten.IsKeyPressed(ebiten.KeyDown): + s.fire(weapon, position, -math.Pi*.75) + case ebiten.IsKeyPressed(ebiten.KeyLeft): + s.fire(weapon, position, 0) + case ebiten.IsKeyPressed(ebiten.KeyRight): + s.fire(weapon, position, math.Pi) + case ebiten.IsKeyPressed(ebiten.KeyUp): + s.fire(weapon, position, math.Pi/2) + case ebiten.IsKeyPressed(ebiten.KeyDown): + s.fire(weapon, position, -math.Pi/2) + } + + return nil +} + +func (_ *fireInputSystem) Draw(_ gohan.EntityID, _ *ebiten.Image) error { + return gohan.ErrSystemWithoutDraw +} diff --git a/examples/twinstick/system/input_move.go b/examples/twinstick/system/input_move.go new file mode 100644 index 0000000..6be9041 --- /dev/null +++ b/examples/twinstick/system/input_move.go @@ -0,0 +1,57 @@ +//go:build example +// +build example + +package system + +import ( + "code.rocketnine.space/tslocum/gohan" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/component" + "github.com/hajimehoshi/ebiten/v2" +) + +type movementInputSystem struct { + player gohan.EntityID +} + +func NewMovementInputSystem(player gohan.EntityID) *movementInputSystem { + return &movementInputSystem{ + player: player, + } +} + +func (s *movementInputSystem) Matches(e gohan.EntityID) bool { + return e == s.player +} + +func (s *movementInputSystem) Update(e gohan.EntityID) error { + velocity := s.player.Component(component.VelocityComponentID).(*component.VelocityComponent) + if ebiten.IsKeyPressed(ebiten.KeyA) { + velocity.X -= 0.5 + if velocity.X < -5 { + velocity.X = -5 + } + } + if ebiten.IsKeyPressed(ebiten.KeyD) { + velocity.X += 0.5 + if velocity.X > 5 { + velocity.X = 5 + } + } + if ebiten.IsKeyPressed(ebiten.KeyW) { + velocity.Y -= 0.5 + if velocity.Y < -5 { + velocity.Y = -5 + } + } + if ebiten.IsKeyPressed(ebiten.KeyS) { + velocity.Y += 0.5 + if velocity.Y > 5 { + velocity.Y = 5 + } + } + return nil +} + +func (s *movementInputSystem) Draw(_ gohan.EntityID, _ *ebiten.Image) error { + return gohan.ErrSystemWithoutDraw +} diff --git a/examples/twinstick/system/movement.go b/examples/twinstick/system/movement.go new file mode 100644 index 0000000..c78e424 --- /dev/null +++ b/examples/twinstick/system/movement.go @@ -0,0 +1,59 @@ +//go:build example +// +build example + +package system + +import ( + "code.rocketnine.space/tslocum/gohan" + "code.rocketnine.space/tslocum/gohan/_examples/twinstick/component" + "github.com/hajimehoshi/ebiten/v2" +) + +type MovementSystem struct { + ScreenW, ScreenH float64 +} + +func (_ *MovementSystem) Matches(entity gohan.EntityID) bool { + position := entity.Component(component.PositionComponentID) + velocity := entity.Component(component.VelocityComponentID) + + return position != nil && velocity != nil +} + +func (s *MovementSystem) Update(entity gohan.EntityID) error { + position := entity.Component(component.PositionComponentID).(*component.PositionComponent) + velocity := entity.Component(component.VelocityComponentID).(*component.VelocityComponent) + + bullet := entity.Component(component.BulletComponentID) + + // Check for collision. + if bullet == nil { + if position.X+velocity.X < 16 { + position.X = 16 + velocity.X = 0 + } else if position.X+velocity.X > s.ScreenW-16 { + position.X = s.ScreenW - 16 + velocity.X = 0 + } + if position.Y+velocity.Y < 16 { + position.Y = 16 + velocity.Y = 0 + } else if position.Y+velocity.Y > s.ScreenH-16 { + position.Y = s.ScreenH - 16 + velocity.Y = 0 + } + } + + position.X, position.Y = position.X+velocity.X, position.Y+velocity.Y + + if bullet == nil { + velocity.X *= 0.95 + velocity.Y *= 0.95 + } + + return nil +} + +func (_ *MovementSystem) Draw(entity gohan.EntityID, screen *ebiten.Image) error { + return gohan.ErrSystemWithoutDraw +} diff --git a/examples/twinstick/system/printinfo.go b/examples/twinstick/system/printinfo.go new file mode 100644 index 0000000..5ff779e --- /dev/null +++ b/examples/twinstick/system/printinfo.go @@ -0,0 +1,43 @@ +//go:build example +// +build example + +package system + +import ( + "fmt" + + "code.rocketnine.space/tslocum/gohan" + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" +) + +type printInfoSystem struct { + img *ebiten.Image + op *ebiten.DrawImageOptions + player gohan.EntityID +} + +func NewPrintInfoSystem(player gohan.EntityID) *printInfoSystem { + p := &printInfoSystem{ + img: ebiten.NewImage(200, 100), + op: &ebiten.DrawImageOptions{}, + player: player, + } + p.op.GeoM.Scale(2, 2) + return p +} + +func (s *printInfoSystem) Matches(e gohan.EntityID) bool { + return e == s.player +} + +func (s *printInfoSystem) Update(_ gohan.EntityID) error { + return gohan.ErrSystemWithoutUpdate +} + +func (s *printInfoSystem) Draw(entity gohan.EntityID, screen *ebiten.Image) error { + s.img.Clear() + ebitenutil.DebugPrint(s.img, fmt.Sprintf("KEY WASD+MOUSE\nTPS %0.0f\nFPS %0.0f", ebiten.CurrentTPS(), ebiten.CurrentFPS())) + screen.DrawImage(s.img, s.op) + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2ec3a46 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module code.rocketnine.space/tslocum/gohan + +go 1.17 + +require github.com/hajimehoshi/ebiten/v2 v2.2.2 + +require ( + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210727001814-0db043d8d5be // indirect + github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 // indirect + golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect + golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect + golang.org/x/mobile v0.0.0-20210902104108-5d9a33257ab5 // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect + golang.org/x/sys v0.0.0-20210917161153-d61c044b1678 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..250c017 --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210727001814-0db043d8d5be h1:vEIVIuBApEBQTEJt19GfhoU+zFSV+sNTa9E9FdnRYfk= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210727001814-0db043d8d5be/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/hajimehoshi/bitmapfont/v2 v2.1.3/go.mod h1:2BnYrkTQGThpr/CY6LorYtt/zEPNzvE/ND69CRTaHMs= +github.com/hajimehoshi/ebiten/v2 v2.2.2 h1:92E+ogdNyH1P/LlvMQ7vonbFDh6bl+O7Ak+H1HX0RX8= +github.com/hajimehoshi/ebiten/v2 v2.2.2/go.mod h1:olKl/qqhMBBAm2oI7Zy292nCtE+nitlmYKNF3UpbFn0= +github.com/hajimehoshi/file2byteslice v0.0.0-20210813153925-5340248a8f41/go.mod h1:CqqAHp7Dk/AqQiwuhV1yT2334qbA/tFWQW0MD2dGqUE= +github.com/hajimehoshi/go-mp3 v0.3.2/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/hajimehoshi/oto/v2 v2.1.0-alpha.2/go.mod h1:rUKQmwMkqmRxe+IAof9+tuYA2ofm8cAWXFmSfzDN8vQ= +github.com/jakecoffman/cp v1.1.0/go.mod h1:JjY/Fp6d8E1CHnu74gWNnU0+b9VzEdUVPoJxg2PsTQg= +github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240 h1:dy+DS31tGEGCsZzB45HmJJNHjur8GDgtRNX9U7HnSX4= +github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240/go.mod h1:3P4UH/k22rXyHIJD2w4h2XMqPX4Of/eySEZq9L6wqc4= +github.com/jfreymuth/oggvorbis v1.0.3/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= +github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs= +golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20210902104108-5d9a33257ab5 h1:peBP2oZO/xVnGMaWMCyFEI0WENsGj71wx5K12mRELHQ= +golang.org/x/mobile v0.0.0-20210902104108-5d9a33257ab5/go.mod h1:c4YKU3ZylDmvbw+H/PSvm42vhdWbuxCzbonauEAP9B8= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210917161153-d61c044b1678 h1:J27LZFQBFoihqXoegpscI10HpjZ7B5WQLLKL2FZXQKw= +golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.6/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/gohan.go b/gohan.go new file mode 100644 index 0000000..f00261f --- /dev/null +++ b/gohan.go @@ -0,0 +1,129 @@ +package gohan + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/hajimehoshi/ebiten/v2" +) + +var ( + gameComponents = make(map[EntityID]map[ComponentID]interface{}) + + gameSystems []System + gameSystemEntities [][]EntityID // Slice of entities matching each system. + + gameSystemReceivesUpdate []bool + gameSystemReceivesDraw []bool + + debug bool +) + +func init() { + debugEnv := os.Getenv("GOHAN_DEBUG") + debugEnv = strings.TrimSpace(debugEnv) + debugEnv = strings.ToLower(debugEnv) + + debug = debugEnv == "1" || debugEnv == "t" || debugEnv == "y" || debugEnv == "on" || debugEnv == "yes" || debugEnv == "true" +} + +// print prints debug information (when enabled). +func print(s string) { + if !debug { + return + } + + log.Println(s) +} + +func attachEntitiesToSystem(system System) { + // This function is always called on a newly added system. + systemID := len(gameSystemEntities) - 1 + + for entity := EntityID(0); entity < nextEntityID; entity++ { + if system.Matches(entity) { + gameSystemEntities[systemID] = append(gameSystemEntities[systemID], entity) + + print(fmt.Sprintf("Attached entity %d to system %d.", entity, systemID)) + } + } +} + +// RegisterSystem registers a system to start receiving Update and Draw calls. +func RegisterSystem(system System) { + gameSystems = append(gameSystems, system) + gameSystemReceivesUpdate = append(gameSystemReceivesUpdate, true) + gameSystemReceivesDraw = append(gameSystemReceivesDraw, true) + gameSystemEntities = append(gameSystemEntities, nil) + + attachEntitiesToSystem(system) +} + +func updateSystem(i int) (int, error) { + updated := 0 + for _, entity := range gameSystemEntities[i] { + err := gameSystems[i].Update(entity) + if err != nil { + if err == ErrSystemWithoutUpdate { + // Unregister system from Update events. + gameSystemReceivesUpdate[i] = false + return 0, nil + } + return 0, fmt.Errorf("failed to update system %d for entity %d: %s", i, entity, err) + } + updated++ + } + return updated, nil +} + +// Update updates the game state. +func Update() error { + var t time.Time + if debug { + t = time.Now() + } + var systems int + for i, registered := range gameSystemReceivesUpdate { + if !registered { + continue + } + + updated, err := updateSystem(i) + if err != nil { + return err + } + + print(fmt.Sprintf("System %d: updated %d entities.", i, updated)) + systems++ + } + if debug { + print(fmt.Sprintf("Finished updating %d systems in %.2fms.", systems, float64(time.Since(t).Microseconds())/1000)) + } + return nil +} + +// Draw draws the game on to the screen. +func Draw(screen *ebiten.Image) error { +DRAWSYSTEMS: + for i, registered := range gameSystemReceivesDraw { + if !registered { + continue + } + + for _, entity := range gameSystemEntities[i] { + err := gameSystems[i].Draw(entity, screen) + if err != nil { + if err == ErrSystemWithoutDraw { + // Unregister system from Draw events. + gameSystemReceivesDraw[i] = false + continue DRAWSYSTEMS + } + return fmt.Errorf("failed to draw system %d for entity %d: %s", i, entity, err) + } + } + } + return nil +} diff --git a/system.go b/system.go new file mode 100644 index 0000000..d073c0f --- /dev/null +++ b/system.go @@ -0,0 +1,33 @@ +package gohan + +import ( + "errors" + + "github.com/hajimehoshi/ebiten/v2" +) + +// System represents a system that runs continuously. While the system must +// implement the Update and Draw methods, a special error value may be returned +// indicating that the system does not utilize one of the methods. System +// methods which return one of these special error values will not be called again. +// +// See ErrSystemWithoutUpdate and ErrSystemWithoutDraw. +type System interface { + // Matches returns whether the provided entity is handled by this system. + Matches(entity EntityID) bool + + // Update is called once for each matching entity each time the game state is updated. + Update(entity EntityID) error + + // Draw is called once for each matching entity each time the game is drawn to the screen. + Draw(entity EntityID, screen *ebiten.Image) error +} + +// Special error values. +var ( + // ErrSystemWithoutUpdate is the error returned when a System does not implement Update. + ErrSystemWithoutUpdate = errors.New("system does not implement update") + + // ErrSystemWithoutDraw is the error returned when a System does not implement Draw. + ErrSystemWithoutDraw = errors.New("system does not implement draw") +)