Skip to content

Instantly share code, notes, and snippets.

@shazow
Created July 30, 2020 14:04
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shazow/c92d0d6245a4c6b9eca417aaa1c2691d to your computer and use it in GitHub Desktop.
Save shazow/c92d0d6245a4c6b9eca417aaa1c2691d to your computer and use it in GitHub Desktop.
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