diff --git a/examples/platformer/README.md b/examples/platformer/README.md new file mode 100644 index 0000000..c6b4e65 --- /dev/null +++ b/examples/platformer/README.md @@ -0,0 +1,11 @@ +# Platformer + +This example demostrates a way to put things together and create a simple platformer game with a +Gopher! + +The pixel art feel is, other than from the pixel art spritesheet, achieved by using a 160x120px +large off-screen canvas, drawing everything to it and then stretching it to fit the window. + +The Gopher spritesheet comes from excellent [Egon Elbre](https://github.com/egonelbre/gophers). + +[Screenshot](screenshot.png) \ No newline at end of file diff --git a/examples/platformer/main.go b/examples/platformer/main.go new file mode 100644 index 0000000..fb3b31c --- /dev/null +++ b/examples/platformer/main.go @@ -0,0 +1,395 @@ +package main + +import ( + "encoding/csv" + "image" + "image/color" + "io" + "math" + "math/rand" + "os" + "strconv" + "time" + + _ "image/png" + + "github.com/faiface/pixel" + "github.com/faiface/pixel/imdraw" + "github.com/faiface/pixel/pixelgl" + "github.com/pkg/errors" + "golang.org/x/image/colornames" +) + +func loadAnimationSheet(sheetPath, descPath string, frameWidth float64) (sheet pixel.Picture, anims map[string][]pixel.Rect, err error) { + // total hack, nicely format the error at the end, so I don't have to type it every time + defer func() { + if err != nil { + err = errors.Wrap(err, "error loading animation sheet") + } + }() + + // open and load the spritesheet + sheetFile, err := os.Open(sheetPath) + if err != nil { + return nil, nil, err + } + defer sheetFile.Close() + sheetImg, _, err := image.Decode(sheetFile) + if err != nil { + return nil, nil, err + } + sheet = pixel.PictureDataFromImage(sheetImg) + + // create a slice of frames inside the spritesheet + var frames []pixel.Rect + for x := 0.0; x+frameWidth <= sheet.Bounds().Max.X(); x += frameWidth { + frames = append(frames, pixel.R( + x, + 0, + x+frameWidth, + sheet.Bounds().H(), + )) + } + + descFile, err := os.Open(descPath) + if err != nil { + return nil, nil, err + } + defer descFile.Close() + + anims = make(map[string][]pixel.Rect) + + // load the animation information, name and interval inside the spritesheet + desc := csv.NewReader(descFile) + for { + anim, err := desc.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, nil, err + } + + name := anim[0] + start, _ := strconv.Atoi(anim[1]) + end, _ := strconv.Atoi(anim[2]) + + anims[name] = frames[start : end+1] + } + + return sheet, anims, nil +} + +type platform struct { + rect pixel.Rect + color color.Color +} + +func (p *platform) draw(imd *imdraw.IMDraw) { + imd.Color(p.color) + imd.Push(p.rect.Min, p.rect.Max) + imd.Rectangle(0) +} + +type gopherPhys struct { + gravity float64 + runSpeed float64 + jumpSpeed float64 + + rect pixel.Rect + vel pixel.Vec + ground bool +} + +func (gp *gopherPhys) update(dt float64, ctrl pixel.Vec, platforms []platform) { + // apply controls + switch { + case ctrl.X() < 0: + gp.vel = gp.vel.WithX(-gp.runSpeed) + case ctrl.X() > 0: + gp.vel = gp.vel.WithX(+gp.runSpeed) + default: + gp.vel = gp.vel.WithX(0) + } + + // apply gravity and velocity + gp.vel += pixel.Y(gp.gravity).Scaled(dt) + gp.rect = gp.rect.Moved(gp.vel.Scaled(dt)) + + // check collisions agains each platform + gp.ground = false + if gp.vel.Y() <= 0 { + for _, p := range platforms { + if gp.rect.Max.X() <= p.rect.Min.X() || gp.rect.Min.X() >= p.rect.Max.X() { + continue + } + if gp.rect.Min.Y() > p.rect.Max.Y() || gp.rect.Min.Y() < p.rect.Max.Y()+gp.vel.Y()*dt { + continue + } + gp.vel = gp.vel.WithY(0) + gp.rect = gp.rect.Moved(pixel.Y(p.rect.Max.Y() - gp.rect.Min.Y())) + gp.ground = true + } + } + + // jump if on the ground and the player wants to jump + if gp.ground && ctrl.Y() > 0 { + gp.vel = gp.vel.WithY(gp.jumpSpeed) + } +} + +type animState int + +const ( + idle animState = iota + running + jumping +) + +type gopherAnim struct { + sheet pixel.Picture + anims map[string][]pixel.Rect + rate float64 + + state animState + counter float64 + dir float64 + + frame pixel.Rect + + sprite *pixel.Sprite +} + +func (ga *gopherAnim) update(dt float64, phys *gopherPhys) { + ga.counter += dt + + // determine the new animation state + var newState animState + switch { + case !phys.ground: + newState = jumping + case phys.vel.Len() == 0: + newState = idle + case phys.vel.Len() > 0: + newState = running + } + + // reset the time counter if the state changed + if ga.state != newState { + ga.state = newState + ga.counter = 0 + } + + // determine the correct animation frame + switch ga.state { + case idle: + ga.frame = ga.anims["Front"][0] + case running: + i := int(math.Floor(ga.counter / ga.rate)) + ga.frame = ga.anims["Run"][i%len(ga.anims["Run"])] + case jumping: + speed := phys.vel.Y() + i := int((-speed/phys.jumpSpeed + 1) / 2 * float64(len(ga.anims["Jump"]))) + if i < 0 { + i = 0 + } + if i >= len(ga.anims["Jump"]) { + i = len(ga.anims["Jump"]) - 1 + } + ga.frame = ga.anims["Jump"][i] + } + + // set the facing direction of the gopher + if phys.vel.X() != 0 { + if phys.vel.X() > 0 { + ga.dir = +1 + } else { + ga.dir = -1 + } + } +} + +func (ga *gopherAnim) draw(t pixel.Target, phys *gopherPhys) { + if ga.sprite == nil { + ga.sprite = pixel.NewSprite(nil, pixel.Rect{}) + } + // draw the correct frame with the correct positon and direction + ga.sprite.Set(ga.sheet, ga.frame) + ga.sprite.SetMatrix(pixel.IM. + ScaledXY(0, pixel.V( + phys.rect.W()/ga.sprite.Frame().W(), + phys.rect.H()/ga.sprite.Frame().H(), + )). + ScaledXY(0, pixel.V(-ga.dir, 1)). + Moved(phys.rect.Center()), + ) + ga.sprite.Draw(t) +} + +type goal struct { + pos pixel.Vec + radius float64 + step float64 + + counter float64 + cols [5]pixel.RGBA +} + +func (g *goal) update(dt float64) { + g.counter += dt + for g.counter > g.step { + g.counter -= g.step + for i := len(g.cols) - 2; i >= 0; i-- { + g.cols[i+1] = g.cols[i] + } + g.cols[0] = randomNiceColor() + } +} + +func (g *goal) draw(imd *imdraw.IMDraw) { + for i := len(g.cols) - 1; i >= 0; i-- { + imd.Color(g.cols[i]) + imd.Push(g.pos) + imd.Circle(float64(i+1)*g.radius/float64(len(g.cols)), 0) + } +} + +func randomNiceColor() pixel.RGBA { +again: + r := rand.Float64() + g := rand.Float64() + b := rand.Float64() + len := math.Sqrt(r*r + g*g + b*b) + if len == 0 { + goto again + } + return pixel.RGB(r/len, g/len, b/len) +} + +func run() { + rand.Seed(time.Now().UnixNano()) + + sheet, anims, err := loadAnimationSheet("sheet.png", "sheet.csv", 12) + if err != nil { + panic(err) + } + + cfg := pixelgl.WindowConfig{ + Title: "Platformer", + Bounds: pixel.R(0, 0, 1024, 768), + VSync: true, + } + win, err := pixelgl.NewWindow(cfg) + if err != nil { + panic(err) + } + + phys := &gopherPhys{ + gravity: -512, + runSpeed: 64, + jumpSpeed: 192, + rect: pixel.R(-6, -7, 6, 7), + } + + anim := &gopherAnim{ + sheet: sheet, + anims: anims, + rate: 1.0 / 10, + dir: +1, + } + + // hardcoded level + platforms := []platform{ + {rect: pixel.R(-50, -34, 50, -32)}, + {rect: pixel.R(20, 0, 70, 2)}, + {rect: pixel.R(-100, 10, -50, 12)}, + {rect: pixel.R(120, -22, 140, -20)}, + {rect: pixel.R(120, -72, 140, -70)}, + {rect: pixel.R(120, -122, 140, -120)}, + {rect: pixel.R(-100, -152, 100, -150)}, + {rect: pixel.R(-150, -127, -140, -125)}, + {rect: pixel.R(-180, -97, -170, -95)}, + {rect: pixel.R(-150, -67, -140, -65)}, + {rect: pixel.R(-180, -37, -170, -35)}, + {rect: pixel.R(-150, -7, -140, -5)}, + } + for i := range platforms { + platforms[i].color = randomNiceColor() + } + + gol := &goal{ + pos: pixel.V(-75, 40), + radius: 18, + step: 1.0 / 7, + } + + canvas := pixelgl.NewCanvas(pixel.R(-160/2, -120/2, 160/2, 120/2)) + imd := imdraw.New(sheet) + imd.Precision(32) + + camPos := pixel.V(0, 0) + + last := time.Now() + for !win.Closed() { + dt := time.Since(last).Seconds() + last = time.Now() + + // lerp the camera position towards the gopher + camPos = pixel.Lerp(camPos, phys.rect.Center(), 1-math.Pow(1.0/64, dt)) + cam := pixel.IM.Moved(-camPos) + canvas.SetMatrix(cam) + + // slow motion with tab + if win.Pressed(pixelgl.KeyTab) { + dt /= 8 + } + + // restart the level on pressing enter + if win.JustPressed(pixelgl.KeyEnter) { + phys.rect = phys.rect.Moved(-phys.rect.Center()) + phys.vel = 0 + } + + // control the gopher with keys + ctrl := pixel.V(0, 0) + if win.Pressed(pixelgl.KeyLeft) { + ctrl -= pixel.X(1) + } + if win.Pressed(pixelgl.KeyRight) { + ctrl += pixel.X(1) + } + if win.JustPressed(pixelgl.KeyUp) { + ctrl = ctrl.WithY(1) + } + + // update the physics and animation + phys.update(dt, ctrl, platforms) + gol.update(dt) + anim.update(dt, phys) + + // draw the scene to the canvas using IMDraw + canvas.Clear(colornames.Black) + imd.Clear() + for _, p := range platforms { + p.draw(imd) + } + gol.draw(imd) + anim.draw(imd, phys) + imd.Draw(canvas) + + // stretch the canvas to the window + win.Clear(colornames.White) + win.SetMatrix(pixel.IM.Scaled(0, + math.Min( + win.Bounds().W()/canvas.Bounds().W(), + win.Bounds().H()/canvas.Bounds().H(), + ), + ).Moved(win.Bounds().Center())) + canvas.Draw(win) + win.Update() + } +} + +func main() { + pixelgl.Run(run) +} diff --git a/examples/platformer/screenshot.png b/examples/platformer/screenshot.png new file mode 100644 index 0000000..4b8b54b Binary files /dev/null and b/examples/platformer/screenshot.png differ diff --git a/examples/platformer/sheet.csv b/examples/platformer/sheet.csv new file mode 100644 index 0000000..159846d --- /dev/null +++ b/examples/platformer/sheet.csv @@ -0,0 +1,9 @@ +Front,0,0 +FrontBlink,1,1 +LookUp,2,2 +Left,3,7 +LeftRight,4,6 +LeftBlink,7,7 +Walk,8,15 +Run,16,23 +Jump,24,26 \ No newline at end of file diff --git a/examples/platformer/sheet.png b/examples/platformer/sheet.png new file mode 100644 index 0000000..8be1b97 Binary files /dev/null and b/examples/platformer/sheet.png differ