BlueMap/BlueMapCore/src/main/java/de/bluecolored/bluemap/core/resources/resourcepack/ResourcePack.java

493 lines
22 KiB
Java

/*
* This file is part of BlueMap, licensed under the MIT License (MIT).
*
* Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
* Copyright (c) contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package de.bluecolored.bluemap.core.resources.resourcepack;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import de.bluecolored.bluemap.api.debug.DebugDump;
import de.bluecolored.bluemap.core.BlueMap;
import de.bluecolored.bluemap.core.logger.Logger;
import de.bluecolored.bluemap.core.resources.BlockColorCalculatorFactory;
import de.bluecolored.bluemap.core.resources.BlockPropertiesConfig;
import de.bluecolored.bluemap.core.resources.ResourcePath;
import de.bluecolored.bluemap.core.resources.adapter.ResourcesGson;
import de.bluecolored.bluemap.core.resources.biome.BiomeConfig;
import de.bluecolored.bluemap.core.resources.resourcepack.blockmodel.BlockModel;
import de.bluecolored.bluemap.core.resources.resourcepack.blockmodel.TextureVariable;
import de.bluecolored.bluemap.core.resources.resourcepack.blockstate.BlockState;
import de.bluecolored.bluemap.core.resources.resourcepack.texture.AnimationMeta;
import de.bluecolored.bluemap.core.resources.resourcepack.texture.Texture;
import de.bluecolored.bluemap.core.util.Tristate;
import de.bluecolored.bluemap.core.world.Biome;
import de.bluecolored.bluemap.core.world.BlockProperties;
import org.jetbrains.annotations.Nullable;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.stream.Stream;
@DebugDump
public class ResourcePack {
public static final ResourcePath<BlockState> MISSING_BLOCK_STATE = new ResourcePath<>("bluemap", "missing");
public static final ResourcePath<BlockModel> MISSING_BLOCK_MODEL = new ResourcePath<>("bluemap", "block/missing");
public static final ResourcePath<Texture> MISSING_TEXTURE = new ResourcePath<>("bluemap", "block/missing");
private final Map<String, ResourcePath<BlockState>> blockStatePaths;
private final Map<ResourcePath<BlockState>, BlockState> blockStates;
private final Map<String, ResourcePath<BlockModel>> blockModelPaths;
private final Map<ResourcePath<BlockModel>, BlockModel> blockModels;
private final Map<String, ResourcePath<Texture>> texturePaths;
private final Map<ResourcePath<Texture>, Texture> textures;
private final Map<ResourcePath<BufferedImage>, BufferedImage> colormaps;
private final BlockColorCalculatorFactory colorCalculatorFactory;
private final BiomeConfig biomeConfig;
private final BlockPropertiesConfig blockPropertiesConfig;
private final LoadingCache<de.bluecolored.bluemap.core.world.BlockState, BlockProperties> blockPropertiesCache;
public ResourcePack() {
this.blockStatePaths = new HashMap<>();
this.blockStates = new HashMap<>();
this.blockModelPaths = new HashMap<>();
this.blockModels = new HashMap<>();
this.texturePaths = new HashMap<>();
this.textures = new HashMap<>();
this.colormaps = new HashMap<>();
this.colorCalculatorFactory = new BlockColorCalculatorFactory();
this.biomeConfig = new BiomeConfig();
this.blockPropertiesConfig = new BlockPropertiesConfig();
this.blockPropertiesCache = Caffeine.newBuilder()
.executor(BlueMap.THREAD_POOL)
.maximumSize(10000)
.build(this::loadBlockProperties);
}
@Nullable
public ResourcePath<BlockState> getBlockStatePath(String formatted) {
return blockStatePaths.get(formatted);
}
@Nullable
public BlockState getBlockState(de.bluecolored.bluemap.core.world.BlockState blockState) {
ResourcePath<BlockState> path = blockStatePaths.get(blockState.getFormatted());
return path != null ? path.getResource(this::getBlockState) : MISSING_BLOCK_STATE.getResource(this::getBlockState);
}
@Nullable
public BlockState getBlockState(ResourcePath<BlockState> path) {
BlockState blockState = blockStates.get(path);
return blockState != null ? blockState : MISSING_BLOCK_STATE.getResource(blockStates::get);
}
public Map<ResourcePath<BlockState>, BlockState> getBlockStates() {
return blockStates;
}
@Nullable
public ResourcePath<BlockModel> getBlockModelPath(String formatted) {
return blockModelPaths.get(formatted);
}
@Nullable
public BlockModel getBlockModel(ResourcePath<BlockModel> path) {
BlockModel blockModel = blockModels.get(path);
return blockModel != null ? blockModel : MISSING_BLOCK_MODEL.getResource(blockModels::get);
}
public Map<ResourcePath<BlockModel>, BlockModel> getBlockModels() {
return blockModels;
}
@Nullable
public ResourcePath<Texture> getTexturePath(String formatted) {
return texturePaths.get(formatted);
}
@Nullable
public Texture getTexture(ResourcePath<Texture> path) {
Texture texture = textures.get(path);
return texture != null ? texture : MISSING_TEXTURE.getResource(textures::get);
}
public Map<ResourcePath<Texture>, Texture> getTextures() {
return textures;
}
public BlockColorCalculatorFactory getColorCalculatorFactory() {
return colorCalculatorFactory;
}
public Biome getBiome(String formatted) {
return biomeConfig.getBiome(formatted);
}
public BlockProperties getBlockProperties(de.bluecolored.bluemap.core.world.BlockState state) {
return blockPropertiesCache.get(state);
}
private BlockProperties loadBlockProperties(de.bluecolored.bluemap.core.world.BlockState state) {
BlockProperties.Builder props = blockPropertiesConfig.getBlockProperties(state).toBuilder();
if (props.isOccluding() == Tristate.UNDEFINED || props.isCulling() == Tristate.UNDEFINED) {
BlockState resource = getBlockState(state);
if (resource != null) {
resource.forEach(state,0, 0, 0, variant -> {
BlockModel model = variant.getModel().getResource(this::getBlockModel);
if (model != null) {
if (props.isOccluding() == Tristate.UNDEFINED) props.occluding(model.isOccluding());
if (props.isCulling() == Tristate.UNDEFINED) props.culling(model.isCulling());
}
});
}
}
return props.build();
}
public synchronized void loadResources(Iterable<Path> roots) throws IOException, InterruptedException {
Logger.global.logInfo("Loading resources...");
for (Path root : roots) {
if (Thread.interrupted()) throw new InterruptedException();
Logger.global.logDebug("Loading resources from: " + root + " ...");
loadResourcePath(root, this::loadResources);
}
Logger.global.logInfo("Loading textures...");
for (Path root : roots) {
if (Thread.interrupted()) throw new InterruptedException();
Logger.global.logDebug("Loading textures from: " + root + " ...");
loadResourcePath(root, this::loadTextures);
}
if (Thread.interrupted()) throw new InterruptedException();
Logger.global.logInfo("Baking resources...");
bake();
Logger.global.logInfo("Resources loaded.");
}
private void loadResourcePath(Path root, PathLoader resourceLoader) throws IOException, InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
if (!Files.isDirectory(root)) {
try (FileSystem fileSystem = FileSystems.newFileSystem(root, (ClassLoader) null)) {
for (Path fsRoot : fileSystem.getRootDirectories()) {
if (!Files.isDirectory(fsRoot)) continue;
loadResourcePath(fsRoot, resourceLoader);
}
} catch (Exception ex) {
Logger.global.logDebug("Failed to read '" + root + "': " + ex);
}
return;
}
// load nested jars from fabric.mod.json if present
Path fabricModJson = root.resolve("fabric.mod.json");
if (Files.isRegularFile(fabricModJson)) {
try (BufferedReader reader = Files.newBufferedReader(fabricModJson)) {
JsonObject rootElement = ResourcesGson.INSTANCE.fromJson(reader, JsonObject.class);
if (rootElement.has("jars")) {
for (JsonElement element : rootElement.getAsJsonArray("jars")) {
Path file = root.resolve(element.getAsJsonObject().get("file").getAsString());
if (Files.exists(file)) loadResourcePath(file, resourceLoader);
}
}
} catch (Exception ex) {
Logger.global.logDebug("Failed to read fabric.mod.json: " + ex);
}
}
resourceLoader.load(root);
}
private void loadResources(Path root) throws IOException {
try {
// do those in parallel
CompletableFuture.allOf(
// load blockstates
CompletableFuture.runAsync(() -> {
list(root.resolve("assets"))
.map(path -> path.resolve("blockstates"))
.filter(Files::isDirectory)
.flatMap(ResourcePack::walk)
.filter(path -> path.getFileName().toString().endsWith(".json"))
.filter(Files::isRegularFile)
.forEach(file -> loadResource(root, file, () -> {
try (BufferedReader reader = Files.newBufferedReader(file)) {
return ResourcesGson.INSTANCE.fromJson(reader, BlockState.class);
}
}, blockStates));
}, BlueMap.THREAD_POOL),
// load blockmodels
CompletableFuture.runAsync(() -> {
list(root.resolve("assets"))
.map(path -> path.resolve("models"))
.flatMap(ResourcePack::list)
.filter(path -> !path.getFileName().toString().equals("item"))
.flatMap(ResourcePack::walk)
.filter(path -> path.getFileName().toString().endsWith(".json"))
.filter(Files::isRegularFile)
.forEach(file -> loadResource(root, file, () -> {
try (BufferedReader reader = Files.newBufferedReader(file)) {
return ResourcesGson.INSTANCE.fromJson(reader, BlockModel.class);
}
}, blockModels));
}, BlueMap.THREAD_POOL),
// load colormaps
CompletableFuture.runAsync(() -> {
walk(root.resolve("assets").resolve("minecraft").resolve("textures").resolve("colormap"))
.filter(path -> path.getFileName().toString().endsWith(".png"))
.filter(Files::isRegularFile)
.forEach(file -> loadResource(root, file, () -> {
try (InputStream in = Files.newInputStream(file)) {
return ImageIO.read(in);
}
}, colormaps));
}, BlueMap.THREAD_POOL),
// load block-color configs
CompletableFuture.runAsync(() -> {
list(root.resolve("assets"))
.map(path -> path.resolve("blockColors.json"))
.filter(Files::isRegularFile)
.forEach(file -> {
try {
colorCalculatorFactory.load(file);
} catch (Exception ex) {
Logger.global.logDebug("Failed to parse resource-file '" + file + "': " + ex);
}
});
}, BlueMap.THREAD_POOL),
// load biome configs
// TODO: move this to datapacks?
CompletableFuture.runAsync(() -> {
list(root.resolve("assets"))
.map(path -> path.resolve("biomes.json"))
.filter(Files::isRegularFile)
.forEach(file -> {
try {
biomeConfig.load(file);
} catch (Exception ex) {
Logger.global.logDebug("Failed to parse resource-file '" + file + "': " + ex);
}
});
list(root.resolve("data"))
.filter(Files::isDirectory)
.forEach(namespace -> list(namespace.resolve("worldgen").resolve("biome"))
.filter(path -> path.getFileName().toString().endsWith(".json"))
.filter(Files::isRegularFile)
.forEach(file -> {
try {
biomeConfig.loadDatapackBiome(namespace.getFileName().toString(), file);
} catch (Exception ex) {
Logger.global.logDebug("Failed to parse resource-file '" + file + "': " + ex);
}
})
);
}, BlueMap.THREAD_POOL),
// load block-properties configs
CompletableFuture.runAsync(() -> {
list(root.resolve("assets"))
.map(path -> path.resolve("blockProperties.json"))
.filter(Files::isRegularFile)
.forEach(file -> {
try {
blockPropertiesConfig.load(file);
} catch (Exception ex) {
Logger.global.logDebug("Failed to parse resource-file '" + file + "': " + ex);
}
});
}, BlueMap.THREAD_POOL)
).join();
} catch (RuntimeException ex) {
Throwable cause = ex.getCause();
if (cause instanceof IOException) throw (IOException) cause;
if (cause != null) throw new IOException(cause);
throw new IOException(ex);
}
}
private void loadTextures(Path root) throws IOException {
try {
// collect all used textures
Set<ResourcePath<Texture>> usedTextures = new HashSet<>();
usedTextures.add(MISSING_TEXTURE);
for (BlockModel model : blockModels.values()) {
for (TextureVariable textureVariable : model.getTextures().values()) {
if (textureVariable.isReference()) continue;
usedTextures.add(textureVariable.getTexturePath());
}
}
// load textures
list(root.resolve("assets"))
.map(path -> path.resolve("textures"))
.flatMap(ResourcePack::walk)
.filter(path -> path.getFileName().toString().endsWith(".png"))
.filter(Files::isRegularFile)
.forEach(file -> loadResource(root, file, () -> {
ResourcePath<Texture> resourcePath = new ResourcePath<>(root.relativize(file));
if (!usedTextures.contains(resourcePath)) return null; // don't load unused textures
// load image
BufferedImage image;
try (InputStream in = Files.newInputStream(file)) {
image = ImageIO.read(in);
}
// load animation
AnimationMeta animation = null;
Path animationPathFile = file.resolveSibling(file.getFileName() + ".mcmeta");
if (Files.exists(animationPathFile)) {
try (Reader in = Files.newBufferedReader(animationPathFile, StandardCharsets.UTF_8)) {
animation = ResourcesGson.INSTANCE.fromJson(in, AnimationMeta.class);
}
}
return Texture.from(resourcePath, image, animation);
}, textures));
} catch (RuntimeException ex) {
Throwable cause = ex.getCause();
if (cause instanceof IOException) throw (IOException) cause;
if (cause != null) throw new IOException(cause);
throw new IOException(ex);
}
}
private void bake() throws IOException, InterruptedException {
// fill path maps
blockStates.keySet().forEach(path -> blockStatePaths.put(path.getFormatted(), path));
blockModels.keySet().forEach(path -> blockModelPaths.put(path.getFormatted(), path));
textures.keySet().forEach(path -> texturePaths.put(path.getFormatted(), path));
// optimize references
for (BlockModel model : blockModels.values()) {
model.optimize(this);
}
if (Thread.interrupted()) throw new InterruptedException();
// apply model parents
for (BlockModel model : blockModels.values()) {
model.applyParent(this);
}
if (Thread.interrupted()) throw new InterruptedException();
// calculate model properties
for (BlockModel model : blockModels.values()) {
model.calculateProperties(this);
}
BufferedImage foliage = new ResourcePath<BufferedImage>("minecraft:colormap/foliage").getResource(colormaps::get);
if (foliage == null) throw new IOException("Failed to bake resource-pack: No foliage-colormap found!");
this.colorCalculatorFactory.setFoliageMap(foliage);
BufferedImage grass = new ResourcePath<BufferedImage>("minecraft:colormap/grass").getResource(colormaps::get);
if (grass == null) throw new IOException("Failed to bake resource-pack: No grass-colormap found!");
this.colorCalculatorFactory.setGrassMap(grass);
}
private <T> void loadResource(Path root, Path file, Loader<T> loader, Map<ResourcePath<T>, T> resultMap) {
try {
ResourcePath<T> resourcePath = new ResourcePath<>(root.relativize(file));
if (resultMap.containsKey(resourcePath)) return; // don't load already present resources
T resource = loader.load();
if (resource == null) return; // don't load missing resources
resourcePath.setResource(resource);
resultMap.put(resourcePath, resource);
} catch (Exception ex) {
Logger.global.logDebug("Failed to parse resource-file '" + file + "': " + ex);
}
}
private static Stream<Path> list(Path root) {
if (!Files.isDirectory(root)) return Stream.empty();
try {
return Files.list(root);
} catch (IOException ex) {
throw new CompletionException(ex);
}
}
private static Stream<Path> walk(Path root) {
if (!Files.exists(root)) return Stream.empty();
if (Files.isRegularFile(root)) return Stream.of(root);
try {
return Files.walk(root);
} catch (IOException ex) {
throw new CompletionException(ex);
}
}
private interface Loader<T> {
T load() throws IOException;
}
private interface PathLoader {
void load(Path root) throws IOException;
}
}