From c0ddb0b2872a0c31dd953b9c3ad7de064013f85c Mon Sep 17 00:00:00 2001 From: faiface Date: Sat, 22 Apr 2017 13:09:23 +0200 Subject: [PATCH] add platformer example --- examples/platformer/README.md | 11 + examples/platformer/main.go | 395 +++++++++++++++++++++++++++++ examples/platformer/screenshot.png | Bin 0 -> 8504 bytes examples/platformer/sheet.csv | 9 + examples/platformer/sheet.png | Bin 0 -> 530 bytes 5 files changed, 415 insertions(+) create mode 100644 examples/platformer/README.md create mode 100644 examples/platformer/main.go create mode 100644 examples/platformer/screenshot.png create mode 100644 examples/platformer/sheet.csv create mode 100644 examples/platformer/sheet.png 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 0000000000000000000000000000000000000000..4b8b54be64b0ad7a2a72d3e3fe2e5c947504f4a9 GIT binary patch literal 8504 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 Uk