package assets import ( "bytes" "embed" "encoding/json" "image" _ "image/png" "io/fs" "log" "log/slog" "path" "strings" "github.com/hajimehoshi/ebiten/v2" ) // Maps image name to image data type AssetRegistry map[string]*ebiten.Image // A helper to pass the registry easier around type assetData struct { Registry AssetRegistry } type AnimationGeo struct { X int `json:"x"` Y int `json:"y"` Width int `json:"w"` Height int `json:"h"` } type AnimationFrame struct { Position AnimationGeo `json:"frame"` Duration int } type AnimationMeta struct { Name string `json:"image"` Geo AnimationGeo `json:"size"` } // Needed to thaw sprite set written by asesprite type AnimationJSON struct { Meta AnimationMeta `json:"Meta"` Frames []AnimationFrame `json:"frames"` } // Animation data type AnimationSet struct { Width, Height int Sprites []*ebiten.Image File string } type AnimationRegistry map[string]AnimationSet //go:embed sprites/*.png fonts/*.ttf levels/*.ldtk shaders/*.kg sprites/*.json var assetfs embed.FS // Called at build time, creates the global asset and animation registries var Assets, Animations = LoadImages() // load pngs and json files func LoadImages() (AssetRegistry, AnimationRegistry) { dir := "sprites" imagedata := &assetData{} imagedata.Registry = AssetRegistry{} rawanimations := []AnimationJSON{} // we use embed.FS to iterate over all files in ./assets/ entries, err := assetfs.ReadDir(dir) if err != nil { log.Fatalf("failed to read assets dir %s: %s", dir, err) } for _, imagefile := range entries { path := path.Join(dir, imagefile.Name()) fd, err := assetfs.Open(path) if err != nil { log.Fatalf("failed to open file %s: %s", imagefile.Name(), err) } defer fd.Close() switch { case strings.HasSuffix(path, ".png"): name, image := ReadImage(imagefile, fd) imagedata.Registry[name] = image case strings.HasSuffix(path, ".json"): animationjson := ReadJson(imagefile, fd) rawanimations = append(rawanimations, animationjson) } slog.Debug("loaded asset", "path", path) } // preprocess animation sprites animations := ProcessAnimations(rawanimations, imagedata) return imagedata.Registry, animations } func ReadImage(imagefile fs.DirEntry, fd fs.File) (string, *ebiten.Image) { name := strings.TrimSuffix(imagefile.Name(), ".png") img, _, err := image.Decode(fd) if err != nil { log.Fatalf("failed to decode image %s: %s", imagefile.Name(), err) } image := ebiten.NewImageFromImage(img) return name, image } func ReadJson(imagefile fs.DirEntry, fd fs.File) AnimationJSON { buf := new(bytes.Buffer) buf.ReadFrom(fd) animationjson := AnimationJSON{} err := json.Unmarshal(buf.Bytes(), &animationjson) if err != nil { log.Fatalf("failed to parse JSON: %s", err) } return animationjson } // turn a raw JSON from Asesprite into something we can use better // internally we also load all the sprites of an animation by using // image.SubImage() on the spriteset matching the animation name. // so, if the animation JSON is called "player-idle.json", then we // expect to receive the spriteset in a file "player-idle.png", which // has of course already been loaded at this stage. These spritesets // must contain a row of sprites. We get the measurements from the JSON. func ProcessAnimations(rawanimations []AnimationJSON, imagedata *assetData) AnimationRegistry { animations := AnimationRegistry{} for _, animation := range rawanimations { animationset := AnimationSet{} animationset.File = strings.TrimSuffix(animation.Meta.Name, ".png") for _, frame := range animation.Frames { sprite := imagedata.Registry[animationset.File].SubImage( image.Rect( frame.Position.X, frame.Position.Y, frame.Position.X+frame.Position.Width, frame.Position.Y+frame.Position.Height, )).(*ebiten.Image) animationset.Sprites = append(animationset.Sprites, sprite) } animationset.Width = animationset.Sprites[0].Bounds().Dx() animationset.Height = animationset.Sprites[0].Bounds().Dy() animations[animationset.File] = animationset } return animations }