From c6877f2ca4fdd03c4282e1aa3c9b32358c9ad6ad Mon Sep 17 00:00:00 2001 From: Benjamin Chausse Date: Thu, 11 Jul 2024 12:17:02 -0400 Subject: Initial Commit --- go.mod | 3 + go.sum | 0 internal/input/keyboard.go | 1 + internal/pacman/pacman.go | 152 ++++++++++++++++++++++++++++ internal/render/instructions.go | 218 ++++++++++++++++++++++++++++++++++++++++ internal/render/renderer.go | 167 ++++++++++++++++++++++++++++++ main.go | 17 ++++ 7 files changed, 558 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/input/keyboard.go create mode 100644 internal/pacman/pacman.go create mode 100644 internal/render/instructions.go create mode 100644 internal/render/renderer.go create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..79127ef --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/ChausseBenjamin/pacgo + +go 1.22.4 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/internal/input/keyboard.go b/internal/input/keyboard.go new file mode 100644 index 0000000..de1ab6f --- /dev/null +++ b/internal/input/keyboard.go @@ -0,0 +1 @@ +package input diff --git a/internal/pacman/pacman.go b/internal/pacman/pacman.go new file mode 100644 index 0000000..cb663af --- /dev/null +++ b/internal/pacman/pacman.go @@ -0,0 +1,152 @@ +package pacman + +import ( + "time" + + "github.com/ChausseBenjamin/pacgo/internal/render" +) + +type Direction uint8 + +const ( + UP Direction = iota + DOWN + LEFT + RIGHT +) + +type Pacman struct { + renderer *render.Renderer + + bright bool + x float64 + y float64 + dir Direction + refreshRate int // times/second the position is updated + + vSpeed float64 // tiles per second + hSpeed float64 // tiles per second + moveTicker *time.Ticker + moveDone chan bool + + blinkRate float64 // stateChanges per second + blinkTicker *time.Ticker + blinkDone chan bool +} + +func (p *Pacman) blink() { + p.bright = !p.bright +} + +func (p *Pacman) drawInstruction() render.DrawInstruction { + return render.DrawInstruction{ + X: int(p.x), + Y: int(p.y), + Content: p.Icon(), + } +} + +// clear will remove the pacman from the screen. This is useful in two cases: +// - Remove pacman's trail/previous position +// - Remove pacman from the screen on death/level change/etc. +// This function will not update pacman's position. This means it must be +// called before updating the position. +func (p *Pacman) clearInstruction() render.ClearInstruction { + return render.ClearInstruction{ + X: int(p.x), + Y: int(p.y), + Size: 1, + } +} + +// move will update pacman's position constantly +// based on the direction it is facing. +func (p *Pacman) move() { + p.renderer.Push(p.clearInstruction()) + switch p.dir { + case UP: + p.y -= (float64(p.vSpeed) * float64(p.refreshRate)) / 1000 + case DOWN: + p.y += (float64(p.vSpeed) * float64(p.refreshRate)) / 1000 + case LEFT: + p.x -= (float64(p.hSpeed) * float64(p.refreshRate)) / 1000 + case RIGHT: + p.x += (float64(p.hSpeed) * float64(p.refreshRate)) / 1000 + } + p.renderer.Push(p.drawInstruction()) +} + +func (p *Pacman) Pos() (float64, float64) { + return p.x, p.y +} + +func (p *Pacman) Redirect(dir Direction) { + p.dir = dir +} + +func (p *Pacman) Icon() string { + icns := map[Direction]map[bool]string{ + UP: {true: "", false: "󰬧"}, + DOWN: {true: "", false: "󰬭"}, + LEFT: {true: "", false: "󰬫"}, + RIGHT: {true: "", false: "󰬩"}, + } + + return icns[p.dir][p.bright] +} + +func (p *Pacman) Start() { + p.blinkDone = make(chan bool) + p.moveDone = make(chan bool) + // fmt.Println(time.Duration(1/p.blinkRate) * time.Second) + blinkTicker := time.NewTicker(time.Duration(float64(time.Second) / p.blinkRate)) + moveTicker := time.NewTicker(time.Duration(float64(time.Second) / float64(p.refreshRate))) + + go func() { + for { + select { + case <-blinkTicker.C: + p.blink() + case <-moveTicker.C: + p.move() + case <-p.blinkDone: + return + case <-p.moveDone: + return + } + } + }() +} + +func (p *Pacman) Stop() { + p.blinkTicker.Stop() + p.moveTicker.Stop() + p.blinkDone <- true + p.moveDone <- true +} + +func NewPacman(x float64, y float64, rdr *render.Renderer) *Pacman { + p := &Pacman{x: x, y: y} + p.dir = RIGHT + p.refreshRate = 200 + p.vSpeed = 0.1 + p.hSpeed = 0.2 + p.blinkRate = 3 + p.renderer = rdr + go p.blink() + go p.move() + return p +} + +// // Icons +// pub const PACMAN_UP_ON :char = ''; +// pub const PACMAN_DOWN_ON :char = ''; +// pub const PACMAN_LEFT_ON :char = ''; +// pub const PACMAN_RIGHT_ON :char = ''; +// pub const GHOST_ON :char = '󰊠'; + +// pub const PACMAN_UP_OFF :char = '󰬧'; +// pub const PACMAN_DOWN_OFF :char = '󰬭'; +// pub const PACMAN_LEFT_OFF :char = '󰬩'; +// pub const PACMAN_RIGHT_OFF :char = '󰬫'; +// pub const GHOST_OFF :char = '󱙝'; diff --git a/internal/render/instructions.go b/internal/render/instructions.go new file mode 100644 index 0000000..685f3f8 --- /dev/null +++ b/internal/render/instructions.go @@ -0,0 +1,218 @@ +package render + +import ( + "fmt" + "io" + "strings" +) + +type ( + trimDir uint8 + overlapType uint8 +) + +const ( + ToLeft trimDir = iota + ToRight + None overlapType = iota + Covers + Within + Left + Right +) + +type Instruction interface { + // Write writes the instruction to the given io.Writer. + Write(io.Writer) + // Pos returns the x,y position of the instruction. + Pos() (int, int) + // Len returns the length of the instructions content. + Len() int + // Trim removes n characters from one side of the instruction. + Trim(n int, d trimDir) Instruction + // Copy creates a new instruction with the same content and position. + Copy() Instruction +} + +func overlap(over, under Instruction) overlapType { + ox, oy := over.Pos() + ux, uy := under.Pos() + os, us := over.Len(), under.Len() + + // wrong row: + if oy != uy { + return None + } + + // totally covers it + if ox <= ux && ox+os >= ux+us { + return Covers + } + + // Overlap on the left: + if ox <= ux && ox+os > ux { + return Left + } + + // Overlap on the right: + if ox < ux+us && ox+os >= ux+us { + return Right + } + + // centered inside: + if ox > ux && ox+os < ux+us { + return Within + } + + // Only other option is not touching + return None +} + +// Squash creates new instruction from overlapping instructions. +// Whenever an instruction would be written over another instruction, +// the one under is trimmed to only include the part that is not covered. +// Three cases are possible: +// - The under instruction is completely covered by the over instruction. +// - The under instruction is partially covered on one side +// - The under instruction is covered in the center (splitting it in two) +// No matter what, the over instruction never changes so only variations of +// under needs to be returned. +func squash(over Instruction, under Instruction, ot overlapType) []Instruction { + ox, _ := over.Pos() + ux, _ := under.Pos() + os, us := over.Len(), under.Len() + switch ot { + case Covers: + return []Instruction{} + case Left: + overlap := ox + os - ux + under.Trim(overlap, ToLeft) + return []Instruction{under} + case Right: + overlap := ux + us - ox + under.Trim(overlap, ToRight) + return []Instruction{under} + case Within: + overlapL := ox - ux + overlapR := ux + us - (ox + os) + left := under.Copy().Trim(overlapL, ToLeft) + under.Trim(overlapR, ToRight) + return []Instruction{left, under} + default: + return []Instruction{under} + } +} + +func reach(i Instruction) string { + x, y := i.Pos() + return fmt.Sprintf("\033[%d;%dH", y, x) +} + +// DrawInstruction is a struct that contains content the renderer +// should draw. +type DrawInstruction struct { + // Content is the string that should be drawn. + Content string + // Pos is the position in the terminal where the content should be drawn. + X int + Y int + // Decorators is a list of ANSI escape codes used to decorate the content. + Decorators []string +} + +func (d DrawInstruction) Pos() (int, int) { + return d.X, d.Y +} + +func (d DrawInstruction) Len() int { + return len(d.Content) +} + +func (d DrawInstruction) Write(w io.Writer) { + msg := reach(d) + strings.Join(d.Decorators, "") + d.Content + io.WriteString(w, msg) +} + +func (d DrawInstruction) Trim(n int, from trimDir) Instruction { + // Prevents having to error check for negative values. + if n < 0 { + n = -n + } + // If n is greater than the length of the content, return nil. + if n > len(d.Content) { + return nil + } + switch from { + case ToLeft: + d.Content = d.Content[n:] + d.X += n + case ToRight: + d.Content = d.Content[:len(d.Content)-n] + } + return &d +} + +func (d DrawInstruction) Copy() Instruction { + return &DrawInstruction{ + Content: d.Content, + X: d.X, + Y: d.Y, + Decorators: d.Decorators, + } +} + +// ClearInstruction is a struct that contains content the renderer should +// clear. they usually accompany a DrawInstruction (i.e. clear the space +// behind a moving object) but not always. +type ClearInstruction struct { + // Len is the number of consecutive characters to clear. + Size int + // Pos is the position in the terminal where the content should be cleared. + X int + Y int +} + +func (c ClearInstruction) Write(w io.Writer) { + // Erase as many characters as the size of the instruction. + msg := reach(c) + "\033[K" + strings.Repeat(" ", c.Size) + // TODO: maybe implement a way to log stuff like writestring errors + io.WriteString(w, msg) +} + +func (c ClearInstruction) Pos() (int, int) { + return c.X, c.Y +} + +func (c ClearInstruction) Len() int { + if c.Size < 0 { + return -c.Size + } + return c.Size +} + +func (c ClearInstruction) Trim(n int, from trimDir) Instruction { + // Prevents having to error check for negative values. + if n < 0 { + n = -n + } + // If n is greater than the length of the content, return nil. + if n > c.Size { + return nil + } + switch from { + case ToLeft: + c.Size -= n + c.X += n + case ToRight: + c.Size -= n + } + return &c +} + +func (c ClearInstruction) Copy() Instruction { + return &ClearInstruction{ + Size: c.Size, + X: c.X, + Y: c.Y, + } +} diff --git a/internal/render/renderer.go b/internal/render/renderer.go new file mode 100644 index 0000000..c527413 --- /dev/null +++ b/internal/render/renderer.go @@ -0,0 +1,167 @@ +package render + +import ( + "io" + "slices" + "time" +) + +const ( + // bufSize how many instruction a buffer should be capable of holding + // before blocking. Increase this if you have a lot of simultaneous + // instructions, but be aware that this will increase memory usage. + bufSize = 9999 + // how many times per second the renderer should refresh the screen. + refreshRate = 60 +) + +// Renderer is used to constantly draw onto the terminal. +// A lazy method is used to draw, where the renderer will only draw +// parts of the screen which it has been instructed to draw. Also, +// the renderer draws at a constant rate, so every object on screen +// is drawn at the same rate/predictably. +// +// To achieve this, the renderer uses two channels that essentially +// act as stacks (they are private so only push/pop methods can be used). +// Whenever a new frame is to be drawn, the renderer makes copies over +// every instruction in it's stacks to temporary buffers (to avoid blocking) +// and proceeds with the drawing. +type Renderer struct { + refreshRate int + // both stacks are only externally accessible through the Push method + // which ensures each instruction is sent to the correct stack. + drwStack chan Instruction + clrStack chan Instruction + stream io.Writer + ticker *time.Ticker + done chan bool +} + +func NewRenderer(stream io.Writer) *Renderer { + return &Renderer{ + refreshRate: refreshRate, + drwStack: make(chan Instruction, bufSize), + clrStack: make(chan Instruction, bufSize), + stream: stream, + } +} + +func (r *Renderer) Start() { + r.ticker = time.NewTicker(time.Duration(float64(time.Second) / float64(r.refreshRate))) + r.done = make(chan bool) + go func() { + for { + select { + case <-r.done: + return + case <-r.ticker.C: + r.DrawFrame() + } + } + }() +} + +func (r *Renderer) Stop() { + r.ticker.Stop() + r.done <- true + // Reset done channel to allow for a new Start + r.done = nil +} + +func (r *Renderer) bufferize(stack chan Instruction) []Instruction { + var buf []Instruction + for { + select { + case i := <-stack: + buf = append(buf, i) + default: + return buf + } + } +} + +func (r *Renderer) Push(i Instruction) { + if i == nil { + // fmt.Println("nil instruction") + return + } + if r.drwStack == nil || r.clrStack == nil { + // fmt.Println("renderer not started") + return + } + // fmt.Printf("Pushing instruction: %T\n", i) + switch inst := i.(type) { + case DrawInstruction: + // fmt.Println("pushing draw") + r.drwStack <- inst + // fmt.Println("pushed draw") + case ClearInstruction: + // fmt.Println("pushing clear") + r.clrStack <- inst + // fmt.Println("pushed clear") + default: + // fmt.Println("unsupported instruction type") + } +} + +// DrawFrame pushes all instructions from the stacks to temporary buffers +// and proceeds with the drawing. Before doing so, it must sanitize certain +// components: +// - If a ClearInstruction overlaps with a DrawInstruction, the ClearInstruction +// part that overlaps is squashed (resize + move). An example of why this is +// necessary is if an object is chasing another. The chased object will send +// instructions to clear it's trail, but if the chaser is faster, it must not +// get cleared. Otherwise, the chaser would be invisible. +// - If two DrawInstructions overlap, the latest instruction is prioritized +// This avoids unnecessary clearing and redrawing. +func (r *Renderer) DrawFrame() { + // NOTE: Buffer indices closer to zero are older instructions + // as they were the first pushed to the stack. + // Create buffers + drwBuf := r.bufferize(r.drwStack) + clrBuf := r.bufferize(r.clrStack) + // fmt.Println(len(drwBuf)) + // fmt.Println(len(clrBuf)) + // squash overlapping clear instructions + for _, drw := range drwBuf { + for j, clr := range clrBuf { + if ot := overlap(drw, clr); ot != None { + newClr := squash(drw, clr, ot) + switch len(newClr) { + case 0: // Complete overlap -> delete the one under + clrBuf = slices.Delete(clrBuf, j, j) + case 1: // Partial overlap -> resize the one under + clrBuf[j] = newClr[0] + default: // Over splits the one under in two -> delete and append parts + clrBuf = slices.Delete(clrBuf, j, j) + clrBuf = append(clrBuf, newClr...) + } + } + } + } + // squash overlapping draw instructions + for i := 0; i < len(drwBuf); i++ { + for j := i + 1; j < len(drwBuf); j++ { + older, newer := drwBuf[i], drwBuf[j] + if ot := overlap(older, newer); ot != None { + newDrw := squash(newer, older, ot) + switch len(newDrw) { + case 0: // Complete overlap -> delete the one under + drwBuf = slices.Delete(drwBuf, i, i) + case 1: // Partial overlap -> resize the one under + drwBuf[i] = newDrw[0] + default: + drwBuf = slices.Delete(drwBuf, i, i) + drwBuf = append(drwBuf, newDrw...) + } + } + } + } + // Draw + for _, clr := range clrBuf { + clr.Write(r.stream) + } + for _, drw := range drwBuf { + drw.Write(r.stream) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b5173eb --- /dev/null +++ b/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "os" + "time" + + "github.com/ChausseBenjamin/pacgo/internal/pacman" + "github.com/ChausseBenjamin/pacgo/internal/render" +) + +func main() { + rdr := render.NewRenderer(os.Stdout) + pm := pacman.NewPacman(3, 3, rdr) + pm.Start() + rdr.Start() + time.Sleep(15 * time.Second) +} -- cgit v1.2.3