Created
July 30, 2020 14:04
-
-
Save shazow/c92d0d6245a4c6b9eca417aaa1c2691d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"context" | |
"fmt" | |
"os" | |
"time" | |
"github.com/gdamore/tcell" | |
) | |
func exit(code int, format string, args ...interface{}) { | |
fmt.Fprintf(os.Stderr, format, args...) | |
os.Exit(code) | |
} | |
func main() { | |
tcell.SetEncodingFallback(tcell.EncodingFallbackASCII) | |
s, err := tcell.NewScreen() | |
if err != nil { | |
exit(1, "failed to create a new screen") | |
} | |
if err = s.Init(); err != nil { | |
exit(1, "failed to initialize screen") | |
} | |
defer s.Fini() | |
s.SetStyle(tcell.StyleDefault. | |
Foreground(tcell.ColorWhite). | |
Background(tcell.ColorBlack)) | |
s.Clear() | |
g := game{ | |
screen: s, | |
maxFPS: 15, | |
height: 30, | |
width: 40, | |
} | |
g.Reset() | |
if err := g.Run(context.Background()); err != nil { | |
exit(2, "run aborted: %s", err) | |
} | |
} | |
type game struct { | |
screen tcell.Screen | |
maxFPS int | |
width int | |
height int | |
entities []Rendered | |
player *player | |
} | |
// Reset game state, initializing all the starting entities | |
func (g *game) Reset() { | |
g.player = &player{X: g.width / 2, Y: g.height} | |
g.entities = []Rendered{ | |
g.player, | |
Invader(5, 3), | |
Invader(10, 3), | |
Invader(15, 3), | |
} | |
} | |
// Run the game, abort it when context is cancelled | |
func (g *game) Run(ctx context.Context) error { | |
ctx, cancel := context.WithCancel(ctx) | |
defer cancel() | |
keyCh := make(chan tcell.Key, 1) | |
go g.listenKeys(ctx, keyCh) | |
var lastTime, currentTime time.Time | |
for { | |
select { | |
case <-ctx.Done(): | |
return nil | |
case key := <-keyCh: | |
if key == tcell.KeyEscape { | |
return nil | |
} | |
if err := g.handle(key); err != nil { | |
return err | |
} | |
default: | |
} | |
currentTime = time.Now() | |
delta := currentTime.Sub(lastTime) | |
fps := int(time.Second / delta) | |
if g.maxFPS > 0 && g.maxFPS < fps { | |
// Throttle game loop to match maxFPS | |
<-time.After(delta - time.Second/time.Duration(g.maxFPS)) | |
continue | |
} | |
if err := g.tick(currentTime.Sub(lastTime)); err != nil { | |
return err | |
} | |
lastTime = currentTime | |
} | |
} | |
// handle is called to react to a key press, it is run in the same goroutine as | |
// the main game loop to avoid race conditions. | |
func (g *game) handle(k tcell.Key) error { | |
switch k { | |
case tcell.KeyLeft: | |
g.player.X -= 1 | |
if g.player.X < 0 { | |
g.player.X = 0 | |
} | |
case tcell.KeyRight: | |
g.player.X += 1 | |
if g.player.X >= g.width { | |
g.player.X = g.width - 1 | |
} | |
case tcell.KeyEnter: // Spawn bullet | |
g.entities = append(g.entities, &bullet{ | |
X: g.player.X, | |
Y: g.player.Y - 1, | |
}) | |
default: | |
// Unhandled, skip clear | |
return nil | |
} | |
g.screen.Clear() | |
return nil | |
} | |
// listenKeys polls for key presses and pipes them into a channel. | |
func (g *game) listenKeys(ctx context.Context, keyCh chan tcell.Key) { | |
for { | |
select { | |
case <-ctx.Done(): | |
return | |
default: | |
} | |
// FIXME: Is it possible to inline this in the game loop rather than do | |
// channel message-passing? The PollEvent docs seem to imply not? | |
ev := g.screen.PollEvent() | |
switch ev := ev.(type) { | |
case *tcell.EventKey: | |
// FIXME: Purge channel if it's full, if we don't care about outdated keys | |
keyCh <- ev.Key() | |
case *tcell.EventResize: | |
g.screen.Sync() | |
} | |
} | |
} | |
// tick performs the tick game loop step | |
func (g *game) tick(delta time.Duration) error { | |
g.screen.Clear() | |
var remove []Rendered | |
for _, entity := range g.entities { | |
if ticker, ok := entity.(Ticked); ok { | |
if !ticker.Tick(delta) { | |
remove = append(remove, entity) | |
continue // Skip rendering | |
} | |
} | |
if err := entity.Render(g.screen); err != nil { | |
return err | |
} | |
} | |
if len(remove) > 0 { | |
// TODO: Remove from rendered | |
} | |
g.screen.Show() | |
return nil | |
} | |
// Entities: | |
type Ticked interface { | |
Tick(delta time.Duration) (keep bool) | |
} | |
type Rendered interface { | |
Render(tcell.Screen) error | |
} | |
func Invader(x, y int) *invader { | |
return &invader{ | |
X: x, Y: y, | |
direction: -1, | |
moveSpeed: 2 * time.Second, | |
turnSpeed: 10 * time.Second, | |
} | |
} | |
type invader struct { | |
X, Y int | |
direction int | |
moveSpeed time.Duration | |
turnSpeed time.Duration | |
timeTurn time.Duration | |
timeMove time.Duration | |
} | |
func (i *invader) Render(s tcell.Screen) error { | |
s.SetCell(i.X, i.Y, tcell.StyleDefault, '👽') | |
return nil | |
} | |
func (i *invader) Tick(delta time.Duration) bool { | |
if i.timeTurn < 0 { | |
i.direction *= -1 | |
if i.direction >= 0 { | |
i.direction = 1 | |
i.Y += 1 | |
} | |
i.timeTurn = i.turnSpeed | |
} | |
if i.timeMove < 0 { | |
i.X += i.direction | |
i.timeMove = i.moveSpeed | |
} | |
i.timeMove -= delta | |
i.timeTurn -= delta | |
return true | |
} | |
type player struct { | |
X, Y int | |
} | |
func (p *player) Render(s tcell.Screen) error { | |
s.SetCell(p.X, p.Y, tcell.StyleDefault, '🚢') | |
return nil | |
} | |
type bullet struct { | |
X, Y int | |
} | |
func (b *bullet) Render(s tcell.Screen) error { | |
s.SetCell(b.X, b.Y, tcell.StyleDefault, '🚀') | |
return nil | |
} | |
func (b *bullet) Tick(delta time.Duration) (keep bool) { | |
b.Y -= 1 | |
return b.Y > 0 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment