package assets import ( "bufio" _ "image/png" "io/fs" "log" "log/slog" "openquell/config" "openquell/util" "os" "path/filepath" "sort" "strconv" "strings" "github.com/hajimehoshi/ebiten/v2" ) var Levels = LoadLevels("levels") var Tiles = InitTiles() // Tile: contains image, identifier (as used in level data) and // additional properties type Tile struct { Id byte Sprite *ebiten.Image Class string Solid bool // wall brick Player bool // player sphere IsPrimary bool // primary player sphere Renderable bool // visible, has sprite Velocity bool // movable Collectible bool // collectible, vanishes once collected Transient bool // turns into brick wall when traversed Destroyable bool // turns into empty floor when bumped into twice Particle int // -1=unused, 0-3 = show image of slice Tiles []*ebiten.Image // has N sprites TileNames []string // same thing, only the names Obstacle bool // is an obstacle/enemy Direction int // obstacle business end shows into this direction } func (tile *Tile) Clone() *Tile { newtile := &Tile{ Id: tile.Id, Sprite: tile.Sprite, Class: tile.Class, Solid: tile.Solid, Player: tile.Player, IsPrimary: tile.IsPrimary, Renderable: tile.Renderable, Velocity: tile.Velocity, Collectible: tile.Collectible, Transient: tile.Transient, Destroyable: tile.Destroyable, Particle: tile.Particle, Tiles: tile.Tiles, TileNames: tile.TileNames, Obstacle: tile.Obstacle, Direction: tile.Direction, } return newtile } const ( Primary bool = true Secondary bool = false ) func NewTilePlayer(isprimary bool) *Tile { tile := &Tile{ Id: 'S', Class: "sphere", Renderable: true, Player: true, Velocity: true, IsPrimary: isprimary, } switch isprimary { case Primary: tile.Sprite = Assets["sphere-blue"] case Secondary: tile.Sprite = Assets["sphere-blue-secondary"] tile.Id = 's' } // primary sprite is always the first one tile.Tiles = []*ebiten.Image{Assets["sphere-blue"], Assets["sphere-blue-secondary"]} return tile } func NewTileBlock(class string) *Tile { return &Tile{ Id: '#', Sprite: Assets[class], Class: class, Solid: true, Renderable: true, } } func NewTileCollectible(class string) *Tile { return &Tile{ Id: 'o', Sprite: Assets[class], Class: class, Solid: false, Renderable: true, Collectible: true, } } func NewTileObstacle(class string, direction int) *Tile { return &Tile{ Id: '+', Sprite: Assets[class], Class: class, Solid: false, Renderable: true, Obstacle: true, Direction: direction, Velocity: true, } } func NewTileParticle(class []string) *Tile { sprites := []*ebiten.Image{} for _, sprite := range class { sprites = append(sprites, Assets[sprite]) } return &Tile{ Id: '*', Class: "particle", Solid: false, Renderable: false, Particle: 0, Tiles: sprites, } } func NewTileTranswall(class []string) *Tile { sprites := []*ebiten.Image{} names := []string{} for _, sprite := range class { sprites = append(sprites, Assets[sprite]) names = append(names, sprite) } return &Tile{ Id: 't', Class: "transwall", Solid: false, Renderable: true, Transient: true, Tiles: sprites, Sprite: sprites[0], // initially use the first TileNames: names, } } func NewTileHiddenDoor(class []string) *Tile { sprites := []*ebiten.Image{} names := []string{} for _, sprite := range class { sprites = append(sprites, Assets[sprite]) names = append(names, sprite) } return &Tile{ Id: 'W', Class: "hiddendoor", Solid: false, Renderable: true, Destroyable: true, Tiles: sprites, Sprite: sprites[0], // initially use the first TileNames: names, } } // used to map level data bytes to actual tiles type TileRegistry map[byte]*Tile // holds a raw level spec: // // Name: the name of the level file w/o the .lvl extension // Background: an image name used as game background for this level // Description: text to display on top // Data: a level spec consisting of chars of the above mapping and spaces, e.g.: // #### // # # // #### // // Each level data must be 20 chars wide (= 640 px width) and 15 chars // high (=480 px height). type RawLevel struct { Name string Description string Background *ebiten.Image Data []byte MinMoves int } func InitTiles() TileRegistry { return TileRegistry{ ' ': {Id: ' ', Class: "floor", Renderable: false}, //'#': NewTileBlock("block-grey32"), '#': NewTileBlock("block-greycolored"), 'B': NewTileBlock("block-orange-32"), 'S': NewTilePlayer(Primary), 's': NewTilePlayer(Secondary), 'o': NewTileCollectible("collectible-orange"), '+': NewTileObstacle("obstacle-star", config.All), '^': NewTileObstacle("obstacle-north", config.North), 'v': NewTileObstacle("obstacle-south", config.South), '<': NewTileObstacle("obstacle-west", config.West), '>': NewTileObstacle("obstacle-east", config.East), '*': NewTileParticle([]string{ //"particle-ring-1", "particle-ring-2", "particle-ring-3", "particle-ring-4", "particle-ring-5", "particle-ring-6", }), 't': NewTileTranswall([]string{"transwall", "block-orange-32"}), 'W': NewTileHiddenDoor([]string{"block-greycolored", "block-greycolored-damaged"}), } } // load levels at compile time into ram, creates a slice of raw levels func LoadLevels(dir string) []RawLevel { levels := []RawLevel{} // we use embed.FS to iterate over all files in ./levels/ entries, err := assetfs.ReadDir(dir) if err != nil { log.Fatalf("failed to read level dir %s: %s", dir, err) } sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) for _, levelfile := range entries { if levelfile.Type().IsRegular() && strings.Contains(levelfile.Name(), ".lvl") { path := filepath.Join("assets", dir) level := ParseRawLevel(path, levelfile) levels = append(levels, level) slog.Debug("loaded level", "path", path, "file", levelfile) } } return levels } func ParseRawLevel(dir string, levelfile fs.DirEntry) RawLevel { fd, err := os.Open(filepath.Join(dir, levelfile.Name())) if err != nil { log.Fatalf("failed to read level file %s: %s", levelfile.Name(), err) } defer fd.Close() name := strings.TrimSuffix(levelfile.Name(), ".lvl") des := "" background := &ebiten.Image{} data := []byte{} minmoves := 0 scanner := bufio.NewScanner(fd) for scanner.Scan() { // ignore any whitespace line := scanner.Text() // ignore empty lines if len(line) == 0 { continue } switch { case strings.Contains(line, "Background:"): haveit := strings.Split(line, ": ") if util.Exists(Assets, haveit[1]) { background = Assets[haveit[1]] } case strings.Contains(line, "Description:"): haveit := strings.Split(line, ": ") des = haveit[1] case strings.Contains(line, "MinMoves:"): haveit := strings.Split(line, ": ") minmoves, err = strconv.Atoi(haveit[1]) if err != nil { log.Fatal("Failed to convert MinMoves to int: %w", err) } default: // all other non-empty and non-equalsign lines are // level definition matrix data, merge thes into data data = append(data, line+"\n"...) } } return RawLevel{ Name: name, Data: data, Background: background, Description: des, MinMoves: minmoves, } }