package mindustry.maps; import arc.*; import arc.assets.*; import arc.assets.loaders.*; import arc.struct.*; import arc.struct.IntSet.*; import arc.files.*; import arc.func.*; import arc.graphics.*; import arc.util.ArcAnnotate.*; import arc.util.*; import arc.util.async.*; import arc.util.io.*; import arc.util.serialization.*; import mindustry.*; import mindustry.content.*; import mindustry.ctype.*; import mindustry.game.EventType.*; import mindustry.game.*; import mindustry.io.*; import mindustry.maps.MapPreviewLoader.*; import mindustry.maps.filters.*; import mindustry.world.*; import mindustry.world.blocks.storage.*; import java.io.*; import static mindustry.Vars.*; public class Maps{ /** List of all built-in maps. Filenames only. */ private static String[] defaultMapNames = {"maze", "fortress", "labyrinth", "islands", "tendrils", "caldera", "wasteland", "shattered", "fork", "triad", "veins", "glacier"}; /** All maps stored in an ordered array. */ private Array maps = new Array<>(); /** Serializer for meta. */ private Json json = new Json(); private ShuffleMode shuffleMode = ShuffleMode.all; private @Nullable MapProvider shuffler; private AsyncExecutor executor = new AsyncExecutor(2); private ObjectSet previewList = new ObjectSet<>(); public ShuffleMode getShuffleMode(){ return shuffleMode; } public void setShuffleMode(ShuffleMode mode){ this.shuffleMode = mode; } /** Set the provider for the map(s) to be played on. Will override the default shuffle mode setting.*/ public void setMapProvider(MapProvider provider){ this.shuffler = provider; } /** @return the next map to shuffle to. May be null, in which case the server should be stopped. */ public @Nullable Map getNextMap(@Nullable Map previous){ return shuffler != null ? shuffler.next(previous) : shuffleMode.next(previous); } /** Returns a list of all maps, including custom ones. */ public Array all(){ return maps; } /** Returns a list of only custom maps. */ public Array customMaps(){ return maps.select(m -> m.custom); } /** Returns a list of only default maps. */ public Array defaultMaps(){ return maps.select(m -> !m.custom); } public Map byName(String name){ return maps.find(m -> m.name().equals(name)); } public Maps(){ Events.on(ClientLoadEvent.class, event -> { maps.sort(); }); Events.on(ContentReloadEvent.class, event -> { reload(); for(Map map : maps){ try{ map.texture = map.previewFile().exists() ? new Texture(map.previewFile()) : new Texture(MapIO.generatePreview(map)); readCache(map); }catch(Exception e){ e.printStackTrace(); } } }); if(Core.assets != null){ ((CustomLoader) Core.assets.getLoader(Content.class)).loaded = this::createAllPreviews; } } /** * Loads a map from the map folder and returns it. Should only be used for zone maps. * Does not add this map to the map list. */ public Map loadInternalMap(String name){ Fi file = tree.get("maps/" + name + "." + mapExtension); try{ return MapIO.createMap(file, false); }catch(IOException e){ throw new RuntimeException(e); } } /** Load all maps. Should be called at application start. */ public void load(){ //defaults; must work try{ for(String name : defaultMapNames){ Fi file = Core.files.internal("maps/" + name + "." + mapExtension); loadMap(file, false); } }catch(IOException e){ throw new RuntimeException(e); } //custom for(Fi file : customMapDirectory.list()){ try{ if(file.extension().equalsIgnoreCase(mapExtension)){ loadMap(file, true); } }catch(Exception e){ Log.err("Failed to load custom map file '@'!", file); Log.err(e); } } //workshop for(Fi file : platform.getWorkshopContent(Map.class)){ try{ Map map = loadMap(file, false); map.workshop = true; map.tags.put("steamid", file.parent().name()); }catch(Exception e){ Log.err("Failed to load workshop map file '@'!", file); Log.err(e); } } //mod mods.listFiles("maps", (mod, file) -> { try{ Map map = loadMap(file, false); map.mod = mod; }catch(Exception e){ Log.err("Failed to load mod map file '@'!", file); Log.err(e); } }); } public void reload(){ for(Map map : maps){ if(map.texture != null){ map.texture.dispose(); map.texture = null; } } maps.clear(); load(); } /** * Save a custom map to the directory. This updates all values and stored data necessary. * The tags are copied to prevent mutation later. */ public Map saveMap(ObjectMap baseTags){ try{ StringMap tags = new StringMap(baseTags); String name = tags.get("name"); if(name == null) throw new IllegalArgumentException("Can't save a map with no name. How did this happen?"); Fi file; //find map with the same exact display name Map other = maps.find(m -> m.name().equals(name)); if(other != null){ //dispose of map if it's already there if(other.texture != null){ other.texture.dispose(); other.texture = null; } maps.remove(other); file = other.file; }else{ file = findFile(); } //create map, write it, etc etc etc Map map = new Map(file, world.width(), world.height(), tags, true); MapIO.writeMap(file, map); if(!headless){ //reset attributes map.teams.clear(); map.spawns = 0; for(int x = 0; x < map.width; x++){ for(int y = 0; y < map.height; y++){ Tile tile = world.rawTile(x, y); if(tile.block() instanceof CoreBlock){ map.teams.add(tile.getTeamID()); } if(tile.overlay() == Blocks.spawn){ map.spawns ++; } } } if(Core.assets.isLoaded(map.previewFile().path() + "." + mapExtension)){ Core.assets.unload(map.previewFile().path() + "." + mapExtension); } Pixmap pix = MapIO.generatePreview(world.tiles); executor.submit(() -> map.previewFile().writePNG(pix)); writeCache(map); map.texture = new Texture(pix); } maps.add(map); maps.sort(); return map; }catch(IOException e){ throw new RuntimeException(e); } } /** Import a map, then save it. This updates all values and stored data necessary. */ public void importMap(Fi file) throws IOException{ Fi dest = findFile(); file.copyTo(dest); Map map = loadMap(dest, true); Exception[] error = {null}; createNewPreview(map, e -> { maps.remove(map); try{ map.file.delete(); }catch(Throwable ignored){ } error[0] = e; }); if(error[0] != null){ throw new IOException(error[0]); } } /** Attempts to run the following code; * catches any errors and attempts to display them in a readable way.*/ public void tryCatchMapError(UnsafeRunnable run){ try{ run.run(); }catch(Throwable e){ Log.err(e); if("Outdated legacy map format".equals(e.getMessage())){ ui.showErrorMessage("$editor.errornot"); }else if(e.getMessage() != null && e.getMessage().contains("Incorrect header!")){ ui.showErrorMessage("$editor.errorheader"); }else{ ui.showException("$editor.errorload", e); } } } /** Removes a map completely. */ public void removeMap(Map map){ if(map.texture != null){ map.texture.dispose(); map.texture = null; } maps.remove(map); map.file.delete(); } /** Reads JSON of filters, returning a new default array if not found.*/ @SuppressWarnings("unchecked") public Array readFilters(String str){ if(str == null || str.isEmpty()){ //create default filters list Array filters = Array.with( new ScatterFilter(){{ flooronto = Blocks.stone; block = Blocks.rock; }}, new ScatterFilter(){{ flooronto = Blocks.shale; block = Blocks.shaleBoulder; }}, new ScatterFilter(){{ flooronto = Blocks.snow; block = Blocks.snowrock; }}, new ScatterFilter(){{ flooronto = Blocks.ice; block = Blocks.snowrock; }}, new ScatterFilter(){{ flooronto = Blocks.sand; block = Blocks.sandBoulder; }} ); addDefaultOres(filters); return filters; }else{ try{ return JsonIO.read(Array.class, str); }catch(Throwable e){ e.printStackTrace(); return readFilters(""); } } } public void addDefaultOres(Array filters){ Array ores = content.blocks().select(b -> b.isOverlay() && b.asFloor().oreDefault); for(Block block : ores){ OreFilter filter = new OreFilter(); filter.threshold = block.asFloor().oreThreshold; filter.scl = block.asFloor().oreScale; filter.ore = block; filters.add(filter); } } public String writeWaves(Array groups){ if(groups == null) return "[]"; StringWriter buffer = new StringWriter(); json.setWriter(new JsonWriter(buffer)); json.writeArrayStart(); for(int i = 0; i < groups.size; i++){ json.writeObjectStart(SpawnGroup.class, SpawnGroup.class); groups.get(i).write(json); json.writeObjectEnd(); } json.writeArrayEnd(); return buffer.toString(); } public Array readWaves(String str){ return str == null ? null : str.equals("[]") ? new Array<>() : Array.with(json.fromJson(SpawnGroup[].class, str)); } public void loadPreviews(){ for(Map map : maps){ //try to load preview if(map.previewFile().exists()){ //this may fail, but calls queueNewPreview Core.assets.load(new AssetDescriptor<>(map.previewFile().path() + "." + mapExtension, Texture.class, new MapPreviewParameter(map))).loaded = t -> map.texture = (Texture)t; try{ readCache(map); }catch(Exception e){ e.printStackTrace(); queueNewPreview(map); } }else{ queueNewPreview(map); } } } private void createAllPreviews(){ Core.app.post(() -> { for(Map map : previewList){ createNewPreview(map, e -> Core.app.post(() -> map.texture = Core.assets.get("sprites/error.png"))); } previewList.clear(); }); } public void queueNewPreview(Map map){ Core.app.post(() -> previewList.add(map)); } private void createNewPreview(Map map, Cons failed){ try{ //if it's here, then the preview failed to load or doesn't exist, make it //this has to be done synchronously! Pixmap pix = MapIO.generatePreview(map); map.texture = new Texture(pix); executor.submit(() -> { try{ map.previewFile().writePNG(pix); writeCache(map); }catch(Exception e){ e.printStackTrace(); } }); }catch(Exception e){ failed.get(e); Log.err("Failed to generate preview!", e); } } private void writeCache(Map map) throws IOException{ try(DataOutputStream stream = new DataOutputStream(map.cacheFile().write(false, Streams.DEFAULT_BUFFER_SIZE))){ stream.write(0); stream.writeInt(map.spawns); stream.write(map.teams.size); IntSetIterator iter = map.teams.iterator(); while(iter.hasNext){ stream.write(iter.next()); } } } private void readCache(Map map) throws IOException{ try(DataInputStream stream = new DataInputStream(map.cacheFile().read(Streams.DEFAULT_BUFFER_SIZE))){ stream.read(); //version map.spawns = stream.readInt(); int teamsize = stream.readByte(); for(int i = 0; i < teamsize; i++){ map.teams.add(stream.read()); } } } /** Find a new filename to put a map to. */ private Fi findFile(){ //find a map name that isn't used. int i = maps.size; while(customMapDirectory.child("map_" + i + "." + mapExtension).exists()){ i++; } return customMapDirectory.child("map_" + i + "." + mapExtension); } private Map loadMap(Fi file, boolean custom) throws IOException{ Map map = MapIO.createMap(file, custom); if(map.name() == null){ throw new IOException("Map name cannot be empty! File: " + file); } maps.add(map); maps.sort(); return map; } public interface MapProvider{ @Nullable Map next(@Nullable Map previous); } public enum ShuffleMode implements MapProvider{ none(map -> null), all(prev -> { Array maps = Array.withArrays(Vars.maps.defaultMaps(), Vars.maps.customMaps()); maps.shuffle(); return maps.find(m -> m != prev || maps.size == 1); }), custom(prev -> { Array maps = Array.withArrays(Vars.maps.customMaps().isEmpty() ? Vars.maps.defaultMaps() : Vars.maps.customMaps()); maps.shuffle(); return maps.find(m -> m != prev || maps.size == 1); }), builtin(prev -> { Array maps = Array.withArrays(Vars.maps.defaultMaps()); maps.shuffle(); return maps.find(m -> m != prev || maps.size == 1); }); private final MapProvider provider; ShuffleMode(MapProvider provider){ this.provider = provider; } @Override public Map next(@Nullable Map previous){ return provider.next(previous); } } }