Skip to content

Instantly share code, notes, and snippets.

@shazow
Created July 30, 2020 14:04
Show Gist options
  • Save shazow/c92d0d6245a4c6b9eca417aaa1c2691d to your computer and use it in GitHub Desktop.
Save shazow/c92d0d6245a4c6b9eca417aaa1c2691d to your computer and use it in GitHub Desktop.

Revisions

  1. shazow created this gist Jul 30, 2020.
    253 changes: 253 additions & 0 deletions invaders.go
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,253 @@
    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
    }