diff --git a/smoke/README.md b/smoke/README.md new file mode 100644 index 0000000..ccaf2e0 --- /dev/null +++ b/smoke/README.md @@ -0,0 +1,8 @@ +# Smoke + +This example implements a smoke particle effect using sprites. It uses a spritesheet with a CSV +description. + +The art in the spritesheet comes from [Kenney](https://kenney.nl/). + +![Screenshot](screenshot.png) \ No newline at end of file diff --git a/smoke/blackSmoke.csv b/smoke/blackSmoke.csv new file mode 100644 index 0000000..7870d7b --- /dev/null +++ b/smoke/blackSmoke.csv @@ -0,0 +1,25 @@ +1543,1146,362,336 +396,0,398,364 +761,1535,386,342 +795,794,351,367 +394,1163,386,364 +1120,1163,377,348 +795,0,368,407 +0,0,395,397 +1164,0,378,415 +781,1163,338,360 +1543,0,372,370 +1148,1535,393,327 +387,1535,373,364 +396,365,371,388 +0,758,378,404 +379,758,378,371 +1543,774,360,371 +1543,1483,350,398 +0,398,382,359 +1164,416,356,382 +1164,799,369,350 +0,1535,386,394 +795,408,366,385 +1543,371,367,402 +0,1163,393,371 \ No newline at end of file diff --git a/smoke/blackSmoke.png b/smoke/blackSmoke.png new file mode 100644 index 0000000..c5ebbb8 Binary files /dev/null and b/smoke/blackSmoke.png differ diff --git a/smoke/main.go b/smoke/main.go new file mode 100644 index 0000000..78dea1a --- /dev/null +++ b/smoke/main.go @@ -0,0 +1,240 @@ +package main + +import ( + "container/list" + "encoding/csv" + "fmt" + "image" + "io" + "math" + "math/rand" + "os" + "strconv" + "time" + + _ "image/png" + + "github.com/faiface/pixel" + "github.com/faiface/pixel/pixelgl" + "golang.org/x/image/colornames" +) + +type particle struct { + Sprite *pixel.Sprite + Pos pixel.Vec + Rot, Scale float64 + Mask pixel.RGBA + Data interface{} +} + +type particles struct { + Generate func() *particle + Update func(dt float64, p *particle) bool + SpawnAvg, SpawnDist float64 + + parts list.List + spawnTime float64 +} + +func (p *particles) UpdateAll(dt float64) { + p.spawnTime -= dt + for p.spawnTime <= 0 { + p.parts.PushFront(p.Generate()) + p.spawnTime += math.Max(0, p.SpawnAvg+rand.NormFloat64()*p.SpawnDist) + } + + for e := p.parts.Front(); e != nil; e = e.Next() { + part := e.Value.(*particle) + if !p.Update(dt, part) { + defer p.parts.Remove(e) + } + } +} + +func (p *particles) DrawAll(t pixel.Target) { + for e := p.parts.Front(); e != nil; e = e.Next() { + part := e.Value.(*particle) + + part.Sprite.SetMatrix(pixel.IM. + Scaled(0, part.Scale). + Rotated(0, part.Rot). + Moved(part.Pos), + ) + part.Sprite.SetColorMask(part.Mask) + part.Sprite.Draw(t) + } +} + +type smokeData struct { + Vel pixel.Vec + Time float64 + Life float64 +} + +type smokeSystem struct { + Sheet pixel.Picture + Rects []pixel.Rect + Orig pixel.Vec + + VelBasis []pixel.Vec + VelDist float64 + + LifeAvg, LifeDist float64 +} + +func (ss *smokeSystem) Generate() *particle { + sd := new(smokeData) + for _, base := range ss.VelBasis { + c := math.Max(0, 1+rand.NormFloat64()*ss.VelDist) + sd.Vel += base.Scaled(c) + } + sd.Vel = sd.Vel.Scaled(1 / float64(len(ss.VelBasis))) + sd.Life = math.Max(0, ss.LifeAvg+rand.NormFloat64()*ss.LifeDist) + + p := new(particle) + p.Data = sd + + p.Pos = ss.Orig + p.Scale = 1 + p.Mask = pixel.Alpha(1) + p.Sprite = pixel.NewSprite(ss.Sheet, ss.Rects[rand.Intn(len(ss.Rects))]) + + return p +} + +func (ss *smokeSystem) Update(dt float64, p *particle) bool { + sd := p.Data.(*smokeData) + sd.Time += dt + + frac := sd.Time / sd.Life + + p.Pos += sd.Vel.Scaled(dt) + p.Scale = 0.5 + frac*1.5 + + const ( + fadeIn = 0.2 + fadeOut = 0.4 + ) + if frac < fadeIn { + p.Mask = pixel.Alpha(math.Pow(frac/fadeIn, 0.75)) + } else if frac >= fadeOut { + p.Mask = pixel.Alpha(math.Pow(1-(frac-fadeOut)/(1-fadeOut), 1.5)) + } else { + p.Mask = pixel.Alpha(1) + } + + return sd.Time < sd.Life +} + +func loadSpriteSheet(sheetPath, descriptionPath string) (sheet pixel.Picture, rects []pixel.Rect, err error) { + 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) + + descriptionFile, err := os.Open(descriptionPath) + if err != nil { + return nil, nil, err + } + defer descriptionFile.Close() + + description := csv.NewReader(descriptionFile) + for { + record, err := description.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, nil, err + } + + x, _ := strconv.ParseFloat(record[0], 64) + y, _ := strconv.ParseFloat(record[1], 64) + w, _ := strconv.ParseFloat(record[2], 64) + h, _ := strconv.ParseFloat(record[3], 64) + + y = sheet.Bounds().H() - y - h + + rects = append(rects, pixel.R(x, y, x+w, y+h)) + } + + return sheet, rects, nil +} + +func run() { + sheet, rects, err := loadSpriteSheet("blackSmoke.png", "blackSmoke.csv") + if err != nil { + panic(err) + } + + cfg := pixelgl.WindowConfig{ + Title: "Smoke", + Bounds: pixel.R(0, 0, 1024, 768), + Resizable: true, + VSync: true, + } + win, err := pixelgl.NewWindow(cfg) + if err != nil { + panic(err) + } + + ss := &smokeSystem{ + Rects: rects, + Orig: 0, + VelBasis: []pixel.Vec{pixel.V(-100, 100), pixel.V(100, 100), pixel.V(0, 100)}, + VelDist: 0.1, + LifeAvg: 7, + LifeDist: 0.5, + } + + p := &particles{ + Generate: ss.Generate, + Update: ss.Update, + SpawnAvg: 0.3, + SpawnDist: 0.1, + } + + batch := pixel.NewBatch(&pixel.TrianglesData{}, sheet) + + var ( + second = time.Tick(time.Second) + frames = 0 + ) + + last := time.Now() + for !win.Closed() { + dt := time.Since(last).Seconds() + last = time.Now() + + p.UpdateAll(dt) + + win.Clear(colornames.Aliceblue) + win.SetMatrix(pixel.IM.Moved(win.Bounds().Center() - pixel.Y(win.Bounds().H()/2))) + + batch.Clear() + p.DrawAll(batch) + batch.Draw(win) + + win.Update() + + frames++ + select { + case <-second: + win.SetTitle(fmt.Sprintf("%s | FPS: %d", cfg.Title, frames)) + frames = 0 + default: + } + } +} + +func main() { + pixelgl.Run(run) +} diff --git a/smoke/screenshot.png b/smoke/screenshot.png new file mode 100644 index 0000000..6cf0056 Binary files /dev/null and b/smoke/screenshot.png differ