it is done
This commit is contained in:
7
core/src/mindustry/mod/ClassAccess.java
Normal file
7
core/src/mindustry/mod/ClassAccess.java
Normal file
File diff suppressed because one or more lines are too long
611
core/src/mindustry/mod/ContentParser.java
Normal file
611
core/src/mindustry/mod/ContentParser.java
Normal file
@@ -0,0 +1,611 @@
|
||||
package mindustry.mod;
|
||||
|
||||
import arc.*;
|
||||
import arc.assets.*;
|
||||
import arc.audio.*;
|
||||
import arc.audio.mock.*;
|
||||
import arc.struct.Array;
|
||||
import arc.struct.*;
|
||||
import arc.files.*;
|
||||
import arc.func.*;
|
||||
import arc.graphics.*;
|
||||
import arc.util.ArcAnnotate.*;
|
||||
import arc.util.*;
|
||||
import arc.util.serialization.*;
|
||||
import arc.util.serialization.Json.*;
|
||||
import arc.util.serialization.Jval.*;
|
||||
import mindustry.*;
|
||||
import mindustry.content.*;
|
||||
import mindustry.content.TechTree.*;
|
||||
import mindustry.ctype.*;
|
||||
import mindustry.entities.Effects.*;
|
||||
import mindustry.entities.bullet.*;
|
||||
import mindustry.entities.type.*;
|
||||
import mindustry.game.*;
|
||||
import mindustry.game.Objectives.*;
|
||||
import mindustry.gen.*;
|
||||
import mindustry.mod.Mods.*;
|
||||
import mindustry.type.*;
|
||||
import mindustry.world.*;
|
||||
import mindustry.world.consumers.*;
|
||||
import mindustry.world.meta.*;
|
||||
|
||||
import java.lang.reflect.*;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public class ContentParser{
|
||||
private static final boolean ignoreUnknownFields = true;
|
||||
private ObjectMap<Class<?>, ContentType> contentTypes = new ObjectMap<>();
|
||||
private StringMap legacyUnitMap = StringMap.of(
|
||||
"Dagger", "GroundUnit",
|
||||
"Eruptor", "GroundUnit",
|
||||
"Titan", "GroundUnit",
|
||||
"Fortress", "GroundUnit",
|
||||
"Crawler", "GroundUnit",
|
||||
"Revenant", "HoverUnit",
|
||||
"Draug", "MinerDrone",
|
||||
"Phantom", "BuilderDrone",
|
||||
"Spirit", "RepairDrone",
|
||||
"Wraith", "FlyingUnit",
|
||||
"Ghoul", "FlyingUnit"
|
||||
);
|
||||
private ObjectMap<Class<?>, FieldParser> classParsers = new ObjectMap<Class<?>, FieldParser>(){{
|
||||
put(Effect.class, (type, data) -> field(Fx.class, data));
|
||||
put(Schematic.class, (type, data) -> {
|
||||
Object result = fieldOpt(Loadouts.class, data);
|
||||
if(result != null){
|
||||
return result;
|
||||
}else{
|
||||
String str = data.asString();
|
||||
if(str.startsWith(Schematics.base64Header)){
|
||||
return Schematics.readBase64(str);
|
||||
}else{
|
||||
return Schematics.read(Vars.tree.get("schematics/" + str + "." + Vars.schematicExtension));
|
||||
}
|
||||
}
|
||||
});
|
||||
put(StatusEffect.class, (type, data) -> {
|
||||
Object result = fieldOpt(StatusEffects.class, data);
|
||||
if(result != null){
|
||||
return result;
|
||||
}
|
||||
StatusEffect effect = new StatusEffect(currentMod.name + "-" + data.getString("name"));
|
||||
readFields(effect, data);
|
||||
return effect;
|
||||
});
|
||||
put(Color.class, (type, data) -> Color.valueOf(data.asString()));
|
||||
put(BulletType.class, (type, data) -> {
|
||||
if(data.isString()){
|
||||
return field(Bullets.class, data);
|
||||
}
|
||||
Class<? extends BulletType> bc = data.has("type") ? resolve(data.getString("type"), "mindustry.entities.bullet") : BasicBulletType.class;
|
||||
data.remove("type");
|
||||
BulletType result = make(bc);
|
||||
readFields(result, data);
|
||||
return result;
|
||||
});
|
||||
put(Sound.class, (type, data) -> {
|
||||
if(fieldOpt(Sounds.class, data) != null) return fieldOpt(Sounds.class, data);
|
||||
if(Vars.headless) return new MockSound();
|
||||
|
||||
String name = "sounds/" + data.asString();
|
||||
String path = Vars.tree.get(name + ".ogg").exists() && !Vars.ios ? name + ".ogg" : name + ".mp3";
|
||||
ModLoadingSound sound = new ModLoadingSound();
|
||||
AssetDescriptor<?> desc = Core.assets.load(path, Sound.class);
|
||||
desc.loaded = result -> sound.sound = (Sound)result;
|
||||
desc.errored = Throwable::printStackTrace;
|
||||
return sound;
|
||||
});
|
||||
put(Objective.class, (type, data) -> {
|
||||
Class<? extends Objective> oc = data.has("type") ? resolve(data.getString("type"), "mindustry.game.Objectives") : ZoneWave.class;
|
||||
data.remove("type");
|
||||
Objective obj = make(oc);
|
||||
readFields(obj, data);
|
||||
return obj;
|
||||
});
|
||||
put(Weapon.class, (type, data) -> {
|
||||
Weapon weapon = new Weapon();
|
||||
readFields(weapon, data);
|
||||
weapon.name = currentMod.name + "-" + weapon.name;
|
||||
return weapon;
|
||||
});
|
||||
}};
|
||||
/** Stores things that need to be parsed fully, e.g. reading fields of content.
|
||||
* This is done to accomodate binding of content names first.*/
|
||||
private Array<Runnable> reads = new Array<>();
|
||||
private Array<Runnable> postreads = new Array<>();
|
||||
private ObjectSet<Object> toBeParsed = new ObjectSet<>();
|
||||
private LoadedMod currentMod;
|
||||
private Content currentContent;
|
||||
|
||||
private Json parser = new Json(){
|
||||
@Override
|
||||
public <T> T readValue(Class<T> type, Class elementType, JsonValue jsonData, Class keyType){
|
||||
T t = internalRead(type, elementType, jsonData, keyType);
|
||||
if(t != null) checkNullFields(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
private <T> T internalRead(Class<T> type, Class elementType, JsonValue jsonData, Class keyType){
|
||||
if(type != null){
|
||||
if(classParsers.containsKey(type)){
|
||||
try{
|
||||
return (T)classParsers.get(type).parse(type, jsonData);
|
||||
}catch(Exception e){
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
//try to parse "item/amount" syntax
|
||||
if(type == ItemStack.class && jsonData.isString() && jsonData.asString().contains("/")){
|
||||
String[] split = jsonData.asString().split("/");
|
||||
|
||||
return (T)fromJson(ItemStack.class, "{item: " + split[0] + ", amount: " + split[1] + "}");
|
||||
}
|
||||
|
||||
//try to parse "liquid/amount" syntax
|
||||
if(jsonData.isString() && jsonData.asString().contains("/")){
|
||||
String[] split = jsonData.asString().split("/");
|
||||
if(type == LiquidStack.class){
|
||||
return (T)fromJson(LiquidStack.class, "{liquid: " + split[0] + ", amount: " + split[1] + "}");
|
||||
}else if(type == ConsumeLiquid.class){
|
||||
return (T)fromJson(ConsumeLiquid.class, "{liquid: " + split[0] + ", amount: " + split[1] + "}");
|
||||
}
|
||||
}
|
||||
|
||||
if(Content.class.isAssignableFrom(type)){
|
||||
ContentType ctype = contentTypes.getThrow(type, () -> new IllegalArgumentException("No content type for class: " + type.getSimpleName()));
|
||||
String prefix = currentMod != null ? currentMod.name + "-" : "";
|
||||
T one = (T)Vars.content.getByName(ctype, prefix + jsonData.asString());
|
||||
if(one != null) return one;
|
||||
T two = (T)Vars.content.getByName(ctype, jsonData.asString());
|
||||
|
||||
if(two != null) return two;
|
||||
throw new IllegalArgumentException("\"" + jsonData.name + "\": No " + ctype + " found with name '" + jsonData.asString() + "'.\nMake sure '" + jsonData.asString() + "' is spelled correctly, and that it really exists!\nThis may also occur because its file failed to parse.");
|
||||
}
|
||||
}
|
||||
|
||||
return super.readValue(type, elementType, jsonData, keyType);
|
||||
}
|
||||
};
|
||||
|
||||
private ObjectMap<ContentType, TypeParser<?>> parsers = ObjectMap.of(
|
||||
ContentType.block, (TypeParser<Block>)(mod, name, value) -> {
|
||||
readBundle(ContentType.block, name, value);
|
||||
|
||||
Block block;
|
||||
|
||||
if(locate(ContentType.block, name) != null){
|
||||
block = locate(ContentType.block, name);
|
||||
|
||||
if(value.has("type")){
|
||||
throw new IllegalArgumentException("When defining properties for an existing block, you must not re-declare its type. The original type will be used. Block: " + name);
|
||||
}
|
||||
}else{
|
||||
//TODO generate dynamically instead of doing.. this
|
||||
Class<? extends Block> type;
|
||||
|
||||
try{
|
||||
type = resolve(getType(value),
|
||||
"mindustry.world",
|
||||
"mindustry.world.blocks",
|
||||
"mindustry.world.blocks.defense",
|
||||
"mindustry.world.blocks.defense.turrets",
|
||||
"mindustry.world.blocks.distribution",
|
||||
"mindustry.world.blocks.liquid",
|
||||
"mindustry.world.blocks.logic",
|
||||
"mindustry.world.blocks.power",
|
||||
"mindustry.world.blocks.production",
|
||||
"mindustry.world.blocks.sandbox",
|
||||
"mindustry.world.blocks.storage",
|
||||
"mindustry.world.blocks.units"
|
||||
);
|
||||
}catch(IllegalArgumentException e){
|
||||
type = Block.class;
|
||||
}
|
||||
|
||||
block = make(type, mod + "-" + name);
|
||||
}
|
||||
|
||||
currentContent = block;
|
||||
|
||||
String[] research = {null};
|
||||
|
||||
//add research tech node
|
||||
if(value.has("research")){
|
||||
research[0] = value.get("research").asString();
|
||||
value.remove("research");
|
||||
}
|
||||
|
||||
read(() -> {
|
||||
if(value.has("consumes")){
|
||||
for(JsonValue child : value.get("consumes")){
|
||||
if(child.name.equals("item")){
|
||||
block.consumes.item(find(ContentType.item, child.asString()));
|
||||
}else if(child.name.equals("items")){
|
||||
block.consumes.add((Consume)parser.readValue(ConsumeItems.class, child));
|
||||
}else if(child.name.equals("liquid")){
|
||||
block.consumes.add((Consume)parser.readValue(ConsumeLiquid.class, child));
|
||||
}else if(child.name.equals("power")){
|
||||
if(child.isNumber()){
|
||||
block.consumes.power(child.asFloat());
|
||||
}else{
|
||||
block.consumes.add((Consume)parser.readValue(ConsumePower.class, child));
|
||||
}
|
||||
}else if(child.name.equals("powerBuffered")){
|
||||
block.consumes.powerBuffered(child.asFloat());
|
||||
}else{
|
||||
throw new IllegalArgumentException("Unknown consumption type: '" + child.name + "' for block '" + block.name + "'.");
|
||||
}
|
||||
}
|
||||
value.remove("consumes");
|
||||
}
|
||||
|
||||
readFields(block, value, true);
|
||||
|
||||
if(block.size > 8){
|
||||
throw new IllegalArgumentException("Blocks cannot be larger than 8x8.");
|
||||
}
|
||||
|
||||
//add research tech node
|
||||
if(research[0] != null){
|
||||
Block parent = find(ContentType.block, research[0]);
|
||||
TechNode baseNode = TechTree.create(parent, block);
|
||||
LoadedMod cur = currentMod;
|
||||
|
||||
postreads.add(() -> {
|
||||
currentContent = block;
|
||||
currentMod = cur;
|
||||
|
||||
TechNode parnode = TechTree.all.find(t -> t.block == parent);
|
||||
if(parnode == null){
|
||||
throw new IllegalArgumentException("Block '" + parent.name + "' isn't in the tech tree, but '" + block.name + "' requires it to be researched.");
|
||||
}
|
||||
if(!parnode.children.contains(baseNode)){
|
||||
parnode.children.add(baseNode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//make block visible by default if there are requirements and no visibility set
|
||||
if(value.has("requirements") && block.buildVisibility == BuildVisibility.hidden){
|
||||
block.buildVisibility = BuildVisibility.shown;
|
||||
}
|
||||
});
|
||||
|
||||
return block;
|
||||
},
|
||||
ContentType.unit, (TypeParser<UnitType>)(mod, name, value) -> {
|
||||
readBundle(ContentType.unit, name, value);
|
||||
|
||||
UnitType unit;
|
||||
if(locate(ContentType.unit, name) == null){
|
||||
Class<BaseUnit> type = resolve(legacyUnitMap.get(Strings.capitalize(getType(value)), getType(value)), "mindustry.entities.type.base");
|
||||
unit = new UnitType(mod + "-" + name, supply(type));
|
||||
}else{
|
||||
unit = locate(ContentType.unit, name);
|
||||
}
|
||||
|
||||
currentContent = unit;
|
||||
read(() -> readFields(unit, value, true));
|
||||
|
||||
return unit;
|
||||
},
|
||||
ContentType.item, parser(ContentType.item, Item::new),
|
||||
ContentType.liquid, parser(ContentType.liquid, Liquid::new),
|
||||
ContentType.mech, parser(ContentType.mech, Mech::new),
|
||||
ContentType.zone, parser(ContentType.zone, Zone::new)
|
||||
);
|
||||
|
||||
private String getString(JsonValue value, String key){
|
||||
if(value.has(key)){
|
||||
return value.getString(key);
|
||||
}else{
|
||||
throw new IllegalArgumentException("You are missing a \"" + key + "\". It must be added before the file can be parsed.");
|
||||
}
|
||||
}
|
||||
|
||||
private String getType(JsonValue value){
|
||||
return getString(value, "type");
|
||||
}
|
||||
|
||||
private <T extends Content> T find(ContentType type, String name){
|
||||
Content c = Vars.content.getByName(type, name);
|
||||
if(c == null) c = Vars.content.getByName(type, currentMod.name + "-" + name);
|
||||
if(c == null) throw new IllegalArgumentException("No " + type + " found with name '" + name + "'");
|
||||
return (T)c;
|
||||
}
|
||||
|
||||
private <T extends Content> TypeParser<T> parser(ContentType type, Func<String, T> constructor){
|
||||
return (mod, name, value) -> {
|
||||
T item;
|
||||
if(Vars.content.getByName(type, name) != null){
|
||||
item = (T)Vars.content.getByName(type, name);
|
||||
readBundle(type, name, value);
|
||||
}else{
|
||||
readBundle(type, name, value);
|
||||
item = constructor.get(mod + "-" + name);
|
||||
}
|
||||
currentContent = item;
|
||||
read(() -> readFields(item, value));
|
||||
return item;
|
||||
};
|
||||
}
|
||||
|
||||
private void readBundle(ContentType type, String name, JsonValue value){
|
||||
UnlockableContent cont = Vars.content.getByName(type, name) instanceof UnlockableContent ?
|
||||
Vars.content.getByName(type, name) : null;
|
||||
|
||||
String entryName = cont == null ? type + "." + currentMod.name + "-" + name + "." : type + "." + cont.name + ".";
|
||||
I18NBundle bundle = Core.bundle;
|
||||
while(bundle.getParent() != null) bundle = bundle.getParent();
|
||||
|
||||
if(value.has("name")){
|
||||
bundle.getProperties().put(entryName + "name", value.getString("name"));
|
||||
if(cont != null) cont.localizedName = value.getString("name");
|
||||
value.remove("name");
|
||||
}
|
||||
|
||||
if(value.has("description")){
|
||||
bundle.getProperties().put(entryName + "description", value.getString("description"));
|
||||
if(cont != null) cont.description = value.getString("description");
|
||||
value.remove("description");
|
||||
}
|
||||
}
|
||||
|
||||
/** Call to read a content's extra info later.*/
|
||||
private void read(Runnable run){
|
||||
Content cont = currentContent;
|
||||
LoadedMod mod = currentMod;
|
||||
reads.add(() -> {
|
||||
this.currentMod = mod;
|
||||
this.currentContent = cont;
|
||||
run.run();
|
||||
});
|
||||
}
|
||||
|
||||
private void init(){
|
||||
for(ContentType type : ContentType.all){
|
||||
Array<Content> arr = Vars.content.getBy(type);
|
||||
if(!arr.isEmpty()){
|
||||
Class<?> c = arr.first().getClass();
|
||||
//get base content class, skipping intermediates
|
||||
while(!(c.getSuperclass() == Content.class || c.getSuperclass() == UnlockableContent.class || Modifier.isAbstract(c.getSuperclass().getModifiers()))){
|
||||
c = c.getSuperclass();
|
||||
}
|
||||
|
||||
contentTypes.put(c, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void attempt(Runnable run){
|
||||
try{
|
||||
run.run();
|
||||
}catch(Throwable t){
|
||||
//don't overwrite double errors
|
||||
markError(currentContent, t);
|
||||
}
|
||||
}
|
||||
|
||||
public void finishParsing(){
|
||||
reads.each(this::attempt);
|
||||
postreads.each(this::attempt);
|
||||
reads.clear();
|
||||
postreads.clear();
|
||||
toBeParsed.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses content from a json file.
|
||||
* @param name the name of the file without its extension
|
||||
* @param json the json to parse
|
||||
* @param type the type of content this is
|
||||
* @param file file that this content is being parsed from
|
||||
* @return the content that was parsed
|
||||
*/
|
||||
public Content parse(LoadedMod mod, String name, String json, Fi file, ContentType type) throws Exception{
|
||||
if(contentTypes.isEmpty()){
|
||||
init();
|
||||
}
|
||||
|
||||
//remove extra # characters to make it valid json... apparently some people have *unquoted* # characters in their json
|
||||
if(file.extension().equals("json")){
|
||||
json = json.replace("#", "\\#");
|
||||
}
|
||||
|
||||
JsonValue value = parser.fromJson(null, Jval.read(json).toString(Jformat.plain));
|
||||
|
||||
if(!parsers.containsKey(type)){
|
||||
throw new SerializationException("No parsers for content type '" + type + "'");
|
||||
}
|
||||
|
||||
currentMod = mod;
|
||||
boolean located = locate(type, name) != null;
|
||||
Content c = parsers.get(type).parse(mod.name, name, value);
|
||||
c.minfo.sourceFile = file;
|
||||
toBeParsed.add(c);
|
||||
|
||||
if(!located){
|
||||
c.minfo.mod = mod;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
public void markError(Content content, LoadedMod mod, Fi file, Throwable error){
|
||||
content.minfo.mod = mod;
|
||||
content.minfo.sourceFile = file;
|
||||
content.minfo.error = makeError(error, file);
|
||||
content.minfo.baseError = error;
|
||||
if(mod != null){
|
||||
mod.erroredContent.add(content);
|
||||
}
|
||||
}
|
||||
|
||||
public void markError(Content content, Throwable error){
|
||||
if(content.minfo != null && !content.hasErrored()){
|
||||
markError(content, content.minfo.mod, content.minfo.sourceFile, error);
|
||||
}
|
||||
}
|
||||
|
||||
private String makeError(Throwable t, Fi file){
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("[lightgray]").append("File: ").append(file.name()).append("[]\n\n");
|
||||
|
||||
if(t.getMessage() != null && t instanceof JsonParseException){
|
||||
builder.append("[accent][[JsonParse][] ").append(":\n").append(t.getMessage());
|
||||
}else{
|
||||
Array<Throwable> causes = Strings.getCauses(t);
|
||||
for(Throwable e : causes){
|
||||
builder.append("[accent][[").append(e.getClass().getSimpleName().replace("Exception", ""))
|
||||
.append("][] ")
|
||||
.append(e.getMessage() != null ?
|
||||
e.getMessage().replace("io.anuke.mindustry.", "").replace("io.anuke.arc.", "") : "").append("\n");
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private <T extends MappableContent> T locate(ContentType type, String name){
|
||||
T first = Vars.content.getByName(type, name); //try vanilla replacement
|
||||
return first != null ? first : Vars.content.getByName(type, currentMod.name + "-" + name);
|
||||
}
|
||||
|
||||
private <T> T make(Class<T> type){
|
||||
try{
|
||||
Constructor<T> cons = type.getDeclaredConstructor();
|
||||
cons.setAccessible(true);
|
||||
return cons.newInstance();
|
||||
}catch(Exception e){
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T make(Class<T> type, String name){
|
||||
try{
|
||||
Constructor<T> cons = type.getDeclaredConstructor(String.class);
|
||||
cons.setAccessible(true);
|
||||
return cons.newInstance(name);
|
||||
}catch(Exception e){
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Prov<T> supply(Class<T> type){
|
||||
try{
|
||||
Constructor<T> cons = type.getDeclaredConstructor();
|
||||
return () -> {
|
||||
try{
|
||||
return cons.newInstance();
|
||||
}catch(Exception e){
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
}catch(Exception e){
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Object field(Class<?> type, JsonValue value){
|
||||
return field(type, value.asString());
|
||||
}
|
||||
|
||||
/** Gets a field from a static class by name, throwing a descriptive exception if not found. */
|
||||
private Object field(Class<?> type, String name){
|
||||
try{
|
||||
Object b = type.getField(name).get(null);
|
||||
if(b == null) throw new IllegalArgumentException(type.getSimpleName() + ": not found: '" + name + "'");
|
||||
return b;
|
||||
}catch(Exception e){
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Object fieldOpt(Class<?> type, JsonValue value){
|
||||
try{
|
||||
return type.getField(value.asString()).get(null);
|
||||
}catch(Exception e){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void checkNullFields(Object object){
|
||||
if(object instanceof Number || object instanceof String || toBeParsed.contains(object)) return;
|
||||
|
||||
parser.getFields(object.getClass()).values().toArray().each(field -> {
|
||||
try{
|
||||
if(field.field.getType().isPrimitive()) return;
|
||||
|
||||
if(field.field.isAnnotationPresent(NonNull.class) && field.field.get(object) == null){
|
||||
throw new RuntimeException("'" + field.field.getName() + "' in " + object.getClass().getSimpleName() + " is missing!");
|
||||
}
|
||||
}catch(Exception e){
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void readFields(Object object, JsonValue jsonMap, boolean stripType){
|
||||
if(stripType) jsonMap.remove("type");
|
||||
readFields(object, jsonMap);
|
||||
}
|
||||
|
||||
private void readFields(Object object, JsonValue jsonMap){
|
||||
toBeParsed.remove(object);
|
||||
Class type = object.getClass();
|
||||
ObjectMap<String, FieldMetadata> fields = parser.getFields(type);
|
||||
for(JsonValue child = jsonMap.child; child != null; child = child.next){
|
||||
FieldMetadata metadata = fields.get(child.name().replace(" ", "_"));
|
||||
if(metadata == null){
|
||||
if(ignoreUnknownFields){
|
||||
Log.warn("{0}: Ignoring unknown field: " + child.name + " (" + type.getName() + ")", object);
|
||||
continue;
|
||||
}else{
|
||||
SerializationException ex = new SerializationException("Field not found: " + child.name + " (" + type.getName() + ")");
|
||||
ex.addTrace(child.trace());
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
Field field = metadata.field;
|
||||
try{
|
||||
field.set(object, parser.readValue(field.getType(), metadata.elementType, child, metadata.keyType));
|
||||
}catch(IllegalAccessException ex){
|
||||
throw new SerializationException("Error accessing field: " + field.getName() + " (" + type.getName() + ")", ex);
|
||||
}catch(SerializationException ex){
|
||||
ex.addTrace(field.getName() + " (" + type.getName() + ")");
|
||||
throw ex;
|
||||
}catch(RuntimeException runtimeEx){
|
||||
SerializationException ex = new SerializationException(runtimeEx);
|
||||
ex.addTrace(child.trace());
|
||||
ex.addTrace(field.getName() + " (" + type.getName() + ")");
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Tries to resolve a class from a list of potential class names. */
|
||||
private <T> Class<T> resolve(String base, String... potentials){
|
||||
if(!base.isEmpty() && Character.isLowerCase(base.charAt(0))) base = Strings.capitalize(base);
|
||||
|
||||
for(String type : potentials){
|
||||
try{
|
||||
return (Class<T>)Class.forName(type + '.' + base);
|
||||
}catch(Exception ignored){
|
||||
try{
|
||||
return (Class<T>)Class.forName(type + '$' + base);
|
||||
}catch(Exception ignored2){
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Types not found: " + base + "." + potentials[0]);
|
||||
}
|
||||
|
||||
private interface FieldParser{
|
||||
Object parse(Class<?> type, JsonValue value) throws Exception;
|
||||
}
|
||||
|
||||
private interface TypeParser<T extends Content>{
|
||||
T parse(String mod, String name, JsonValue value) throws Exception;
|
||||
}
|
||||
|
||||
}
|
||||
27
core/src/mindustry/mod/Mod.java
Normal file
27
core/src/mindustry/mod/Mod.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package mindustry.mod;
|
||||
|
||||
import arc.files.*;
|
||||
import arc.util.*;
|
||||
import mindustry.*;
|
||||
|
||||
public class Mod{
|
||||
/** @return the config file for this plugin, as the file 'mods/[plugin-name]/config.json'.*/
|
||||
public Fi getConfig(){
|
||||
return Vars.mods.getConfig(this);
|
||||
}
|
||||
|
||||
/** Called after all plugins have been created and commands have been registered.*/
|
||||
public void init(){
|
||||
|
||||
}
|
||||
|
||||
/** Register any commands to be used on the server side, e.g. from the console. */
|
||||
public void registerServerCommands(CommandHandler handler){
|
||||
|
||||
}
|
||||
|
||||
/** Register any commands to be used on the client side, e.g. sent from an in-game player.. */
|
||||
public void registerClientCommands(CommandHandler handler){
|
||||
|
||||
}
|
||||
}
|
||||
135
core/src/mindustry/mod/ModLoadingSound.java
Normal file
135
core/src/mindustry/mod/ModLoadingSound.java
Normal file
@@ -0,0 +1,135 @@
|
||||
package mindustry.mod;
|
||||
|
||||
import arc.audio.*;
|
||||
import arc.audio.mock.*;
|
||||
import arc.math.geom.*;
|
||||
import arc.util.ArcAnnotate.*;
|
||||
|
||||
public class ModLoadingSound implements Sound{
|
||||
public @NonNull Sound sound = new MockSound();
|
||||
|
||||
@Override
|
||||
public float calcPan(float x, float y){
|
||||
return sound.calcPan(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public float calcVolume(float x, float y){
|
||||
return sound.calcVolume(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public float calcFalloff(float x, float y){
|
||||
return sound.calcFalloff(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int at(float x, float y, float pitch){
|
||||
return sound.at(x, y, pitch);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int at(float x, float y){
|
||||
return sound.at(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int at(Position pos){
|
||||
return sound.at(pos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int at(Position pos, float pitch){
|
||||
return sound.at(pos, pitch);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int play(){
|
||||
return sound.play();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int play(float volume){
|
||||
return sound.play(volume);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int play(float volume, float pitch, float pan){
|
||||
return sound.play(volume, pitch, pan);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int loop(){
|
||||
return sound.loop();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int loop(float volume){
|
||||
return sound.loop(volume);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int loop(float volume, float pitch, float pan){
|
||||
return sound.loop(volume, pitch, pan);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(){
|
||||
sound.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pause(){
|
||||
sound.pause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resume(){
|
||||
sound.resume();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose(){
|
||||
sound.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(int soundId){
|
||||
sound.stop(soundId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pause(int soundId){
|
||||
sound.pause(soundId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resume(int soundId){
|
||||
sound.resume(soundId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLooping(int soundId, boolean looping){
|
||||
sound.setLooping(soundId, looping);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPitch(int soundId, float pitch){
|
||||
sound.setPitch(soundId, pitch);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setVolume(int soundId, float volume){
|
||||
sound.setVolume(soundId, volume);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPan(int soundId, float pan, float volume){
|
||||
sound.setPan(soundId, pan, volume);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDisposed(){
|
||||
return sound.isDisposed();
|
||||
}
|
||||
}
|
||||
790
core/src/mindustry/mod/Mods.java
Normal file
790
core/src/mindustry/mod/Mods.java
Normal file
@@ -0,0 +1,790 @@
|
||||
package mindustry.mod;
|
||||
|
||||
import arc.*;
|
||||
import arc.assets.*;
|
||||
import arc.struct.*;
|
||||
import arc.files.*;
|
||||
import arc.func.*;
|
||||
import arc.graphics.*;
|
||||
import arc.graphics.Texture.*;
|
||||
import arc.graphics.g2d.*;
|
||||
import arc.graphics.g2d.TextureAtlas.*;
|
||||
import arc.scene.ui.*;
|
||||
import arc.util.*;
|
||||
import arc.util.ArcAnnotate.*;
|
||||
import arc.util.io.*;
|
||||
import arc.util.serialization.*;
|
||||
import arc.util.serialization.Jval.*;
|
||||
import mindustry.core.*;
|
||||
import mindustry.ctype.*;
|
||||
import mindustry.game.EventType.*;
|
||||
import mindustry.gen.*;
|
||||
import mindustry.graphics.*;
|
||||
import mindustry.graphics.MultiPacker.*;
|
||||
import mindustry.plugin.*;
|
||||
import mindustry.type.*;
|
||||
import mindustry.ui.*;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
|
||||
import static mindustry.Vars.*;
|
||||
|
||||
public class Mods implements Loadable{
|
||||
private Json json = new Json();
|
||||
private @Nullable Scripts scripts;
|
||||
private ContentParser parser = new ContentParser();
|
||||
private ObjectMap<String, Array<Fi>> bundles = new ObjectMap<>();
|
||||
private ObjectSet<String> specialFolders = ObjectSet.with("bundles", "sprites", "sprites-override");
|
||||
|
||||
private int totalSprites;
|
||||
private MultiPacker packer;
|
||||
|
||||
private Array<LoadedMod> mods = new Array<>();
|
||||
private ObjectMap<Class<?>, ModMeta> metas = new ObjectMap<>();
|
||||
private boolean requiresReload;
|
||||
|
||||
public Mods(){
|
||||
Events.on(ClientLoadEvent.class, e -> Core.app.post(this::checkWarnings));
|
||||
Events.on(ContentReloadEvent.class, e -> Core.app.post(this::checkWarnings));
|
||||
}
|
||||
|
||||
/** Returns a file named 'config.json' in a special folder for the specified plugin.
|
||||
* Call this in init(). */
|
||||
public Fi getConfig(Mod mod){
|
||||
ModMeta load = metas.get(mod.getClass());
|
||||
if(load == null) throw new IllegalArgumentException("Mod is not loaded yet (or missing)!");
|
||||
return modDirectory.child(load.name).child("config.json");
|
||||
}
|
||||
|
||||
/** Returns a list of files per mod subdirectory. */
|
||||
public void listFiles(String directory, Cons2<LoadedMod, Fi> cons){
|
||||
eachEnabled(mod -> {
|
||||
Fi file = mod.root.child(directory);
|
||||
if(file.exists()){
|
||||
for(Fi child : file.list()){
|
||||
cons.get(mod, child);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return the loaded mod found by class, or null if not found. */
|
||||
public @Nullable LoadedMod getMod(Class<? extends Mod> type){
|
||||
return mods.find(m -> m.enabled() && m.main != null && m.main.getClass() == type);//loaded.find(l -> l.mod != null && l.mod.getClass() == type);
|
||||
}
|
||||
|
||||
/** Imports an external mod file.*/
|
||||
public void importMod(Fi file) throws IOException{
|
||||
Fi dest = modDirectory.child(file.name());
|
||||
if(dest.exists()){
|
||||
throw new IOException("A mod with the same filename already exists!");
|
||||
}
|
||||
|
||||
file.copyTo(dest);
|
||||
try{
|
||||
mods.add(loadMod(dest));
|
||||
requiresReload = true;
|
||||
}catch(IOException e){
|
||||
dest.delete();
|
||||
throw e;
|
||||
}catch(Throwable t){
|
||||
dest.delete();
|
||||
throw new IOException(t);
|
||||
}
|
||||
}
|
||||
|
||||
/** Repacks all in-game sprites. */
|
||||
@Override
|
||||
public void loadAsync(){
|
||||
if(!mods.contains(LoadedMod::enabled)) return;
|
||||
Time.mark();
|
||||
|
||||
packer = new MultiPacker();
|
||||
|
||||
eachEnabled(mod -> {
|
||||
Array<Fi> sprites = mod.root.child("sprites").findAll(f -> f.extension().equals("png"));
|
||||
Array<Fi> overrides = mod.root.child("sprites-override").findAll(f -> f.extension().equals("png"));
|
||||
packSprites(sprites, mod, true);
|
||||
packSprites(overrides, mod, false);
|
||||
Log.debug("Packed {0} images for mod '{1}'.", sprites.size + overrides.size, mod.meta.name);
|
||||
totalSprites += sprites.size + overrides.size;
|
||||
});
|
||||
|
||||
for(AtlasRegion region : Core.atlas.getRegions()){
|
||||
PageType type = getPage(region);
|
||||
if(!packer.has(type, region.name)){
|
||||
packer.add(type, region.name, Core.atlas.getPixmap(region));
|
||||
}
|
||||
}
|
||||
|
||||
Log.debug("Time to pack textures: {0}", Time.elapsed());
|
||||
}
|
||||
|
||||
private void packSprites(Array<Fi> sprites, LoadedMod mod, boolean prefix){
|
||||
for(Fi file : sprites){
|
||||
try(InputStream stream = file.read()){
|
||||
byte[] bytes = Streams.copyStreamToByteArray(stream, Math.max((int)file.length(), 512));
|
||||
Pixmap pixmap = new Pixmap(bytes, 0, bytes.length);
|
||||
packer.add(getPage(file), (prefix ? mod.name + "-" : "") + file.nameWithoutExtension(), new PixmapRegion(pixmap));
|
||||
pixmap.dispose();
|
||||
}catch(IOException e){
|
||||
Core.app.post(() -> {
|
||||
Log.err("Error packing images for mod: {0}", mod.meta.name);
|
||||
e.printStackTrace();
|
||||
if(!headless) ui.showException(e);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
totalSprites += sprites.size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadSync(){
|
||||
if(packer == null) return;
|
||||
Time.mark();
|
||||
|
||||
//get textures packed
|
||||
if(totalSprites > 0){
|
||||
TextureFilter filter = Core.settings.getBool("linear") ? TextureFilter.Linear : TextureFilter.Nearest;
|
||||
|
||||
//flush so generators can use these sprites
|
||||
packer.flush(filter, Core.atlas);
|
||||
|
||||
//generate new icons
|
||||
for(Array<Content> arr : content.getContentMap()){
|
||||
arr.each(c -> {
|
||||
if(c instanceof UnlockableContent && c.minfo.mod != null){
|
||||
UnlockableContent u = (UnlockableContent)c;
|
||||
u.createIcons(packer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Core.atlas = packer.flush(filter, new TextureAtlas());
|
||||
Core.atlas.setErrorRegion("error");
|
||||
Log.debug("Total pages: {0}", Core.atlas.getTextures().size);
|
||||
}
|
||||
|
||||
packer.dispose();
|
||||
packer = null;
|
||||
Log.debug("Time to update textures: {0}", Time.elapsed());
|
||||
}
|
||||
|
||||
private PageType getPage(AtlasRegion region){
|
||||
return
|
||||
region.getTexture() == Core.atlas.find("white").getTexture() ? PageType.main :
|
||||
region.getTexture() == Core.atlas.find("stone1").getTexture() ? PageType.environment :
|
||||
region.getTexture() == Core.atlas.find("clear-editor").getTexture() ? PageType.editor :
|
||||
region.getTexture() == Core.atlas.find("zone-groundZero").getTexture() ? PageType.zone :
|
||||
region.getTexture() == Core.atlas.find("whiteui").getTexture() ? PageType.ui :
|
||||
PageType.main;
|
||||
}
|
||||
|
||||
private PageType getPage(Fi file){
|
||||
String parent = file.parent().name();
|
||||
return
|
||||
parent.equals("environment") ? PageType.environment :
|
||||
parent.equals("editor") ? PageType.editor :
|
||||
parent.equals("zones") ? PageType.zone :
|
||||
parent.equals("ui") || file.parent().parent().name().equals("ui") ? PageType.ui :
|
||||
PageType.main;
|
||||
}
|
||||
|
||||
/** Removes a mod file and marks it for requiring a restart. */
|
||||
public void removeMod(LoadedMod mod){
|
||||
if(mod.root instanceof ZipFi){
|
||||
mod.root.delete();
|
||||
}
|
||||
|
||||
boolean deleted = mod.file.isDirectory() ? mod.file.deleteDirectory() : mod.file.delete();
|
||||
|
||||
if(!deleted){
|
||||
ui.showErrorMessage("$mod.delete.error");
|
||||
return;
|
||||
}
|
||||
mods.remove(mod);
|
||||
requiresReload = true;
|
||||
}
|
||||
|
||||
public Scripts getScripts(){
|
||||
if(scripts == null) scripts = platform.createScripts();
|
||||
return scripts;
|
||||
}
|
||||
|
||||
/** @return whether the scripting engine has been initialized. */
|
||||
public boolean hasScripts(){
|
||||
return scripts != null;
|
||||
}
|
||||
|
||||
public boolean requiresReload(){
|
||||
return requiresReload;
|
||||
}
|
||||
|
||||
/** Loads all mods from the folder, but does not call any methods on them.*/
|
||||
public void load(){
|
||||
for(Fi file : modDirectory.list()){
|
||||
if(!file.extension().equals("jar") && !file.extension().equals("zip") && !(file.isDirectory() && (file.child("mod.json").exists() || file.child("mod.hjson").exists()))) continue;
|
||||
|
||||
Log.debug("[Mods] Loading mod {0}", file);
|
||||
try{
|
||||
LoadedMod mod = loadMod(file);
|
||||
mods.add(mod);
|
||||
}catch(Exception e){
|
||||
Log.err("Failed to load mod file {0}. Skipping.", file);
|
||||
Log.err(e);
|
||||
}
|
||||
}
|
||||
|
||||
//load workshop mods now
|
||||
for(Fi file : platform.getWorkshopContent(LoadedMod.class)){
|
||||
try{
|
||||
LoadedMod mod = loadMod(file);
|
||||
mods.add(mod);
|
||||
mod.addSteamID(file.name());
|
||||
}catch(Exception e){
|
||||
Log.err("Failed to load mod workshop file {0}. Skipping.", file);
|
||||
Log.err(e);
|
||||
}
|
||||
}
|
||||
|
||||
resolveModState();
|
||||
sortMods();
|
||||
|
||||
buildFiles();
|
||||
}
|
||||
|
||||
private void sortMods(){
|
||||
//sort mods to make sure servers handle them properly and they appear correctly in the dialog
|
||||
mods.sort(Structs.comps(Structs.comparingInt(m -> m.state.ordinal()), Structs.comparing(m -> m.name)));
|
||||
}
|
||||
|
||||
private void resolveModState(){
|
||||
mods.each(this::updateDependencies);
|
||||
|
||||
for(LoadedMod mod : mods){
|
||||
mod.state =
|
||||
!mod.isSupported() ? ModState.unsupported :
|
||||
mod.hasUnmetDependencies() ? ModState.missingDependencies :
|
||||
!mod.shouldBeEnabled() ? ModState.disabled :
|
||||
ModState.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDependencies(LoadedMod mod){
|
||||
mod.dependencies.clear();
|
||||
mod.missingDependencies.clear();
|
||||
mod.dependencies = mod.meta.dependencies.map(this::locateMod);
|
||||
|
||||
for(int i = 0; i < mod.dependencies.size; i++){
|
||||
if(mod.dependencies.get(i) == null){
|
||||
mod.missingDependencies.add(mod.meta.dependencies.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void topoSort(LoadedMod mod, Array<LoadedMod> stack, ObjectSet<LoadedMod> visited){
|
||||
visited.add(mod);
|
||||
mod.dependencies.each(m -> !visited.contains(m), m -> topoSort(m, stack, visited));
|
||||
stack.add(mod);
|
||||
}
|
||||
|
||||
/** @return mods ordered in the correct way needed for dependencies. */
|
||||
private Array<LoadedMod> orderedMods(){
|
||||
ObjectSet<LoadedMod> visited = new ObjectSet<>();
|
||||
Array<LoadedMod> result = new Array<>();
|
||||
eachEnabled(mod -> {
|
||||
if(!visited.contains(mod)){
|
||||
topoSort(mod, result, visited);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private LoadedMod locateMod(String name){
|
||||
return mods.find(mod -> mod.enabled() && mod.name.equals(name));
|
||||
}
|
||||
|
||||
private void buildFiles(){
|
||||
for(LoadedMod mod : orderedMods()){
|
||||
boolean zipFolder = !mod.file.isDirectory() && mod.root.parent() != null;
|
||||
String parentName = zipFolder ? mod.root.name() : null;
|
||||
for(Fi file : mod.root.list()){
|
||||
//ignore special folders like bundles or sprites
|
||||
if(file.isDirectory() && !specialFolders.contains(file.name())){
|
||||
//TODO calling child/parent on these files will give you gibberish; create wrapper class.
|
||||
file.walk(f -> tree.addFile(mod.file.isDirectory() ? f.path().substring(1 + mod.file.path().length()) :
|
||||
zipFolder ? f.path().substring(parentName.length() + 1) : f.path(), f));
|
||||
}
|
||||
}
|
||||
|
||||
//load up bundles.
|
||||
Fi folder = mod.root.child("bundles");
|
||||
if(folder.exists()){
|
||||
for(Fi file : folder.list()){
|
||||
if(file.name().startsWith("bundle") && file.extension().equals("properties")){
|
||||
String name = file.nameWithoutExtension();
|
||||
bundles.getOr(name, Array::new).add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//add new keys to each bundle
|
||||
I18NBundle bundle = Core.bundle;
|
||||
while(bundle != null){
|
||||
String str = bundle.getLocale().toString();
|
||||
String locale = "bundle" + (str.isEmpty() ? "" : "_" + str);
|
||||
for(Fi file : bundles.getOr(locale, Array::new)){
|
||||
try{
|
||||
PropertiesUtils.load(bundle.getProperties(), file.reader());
|
||||
}catch(Exception e){
|
||||
Log.err("Error loading bundle: " + file + "/" + locale, e);
|
||||
}
|
||||
}
|
||||
bundle = bundle.getParent();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check all warnings related to content and show relevant dialogs. Client only. */
|
||||
private void checkWarnings(){
|
||||
//show 'scripts have errored' info
|
||||
if(scripts != null && scripts.hasErrored()){
|
||||
Core.settings.getBoolOnce("scripts-errored2", () -> ui.showErrorMessage("$mod.scripts.unsupported"));
|
||||
}
|
||||
|
||||
//show list of errored content
|
||||
if(mods.contains(LoadedMod::hasContentErrors)){
|
||||
ui.loadfrag.hide();
|
||||
new Dialog(""){{
|
||||
|
||||
setFillParent(true);
|
||||
cont.margin(15);
|
||||
cont.add("$error.title");
|
||||
cont.row();
|
||||
cont.addImage().width(300f).pad(2).colspan(2).height(4f).color(Color.scarlet);
|
||||
cont.row();
|
||||
cont.add("$mod.errors").wrap().growX().center().get().setAlignment(Align.center);
|
||||
cont.row();
|
||||
cont.pane(p -> {
|
||||
mods.each(m -> m.enabled() && m.hasContentErrors(), m -> {
|
||||
p.add(m.name).color(Pal.accent).left();
|
||||
p.row();
|
||||
p.addImage().fillX().pad(4).color(Pal.accent);
|
||||
p.row();
|
||||
p.table(d -> {
|
||||
d.left().marginLeft(15f);
|
||||
for(Content c : m.erroredContent){
|
||||
d.add(c.minfo.sourceFile.nameWithoutExtension()).left().padRight(10);
|
||||
d.addImageTextButton("$details", Icon.arrowDownSmall, Styles.transt, () -> {
|
||||
new Dialog(""){{
|
||||
setFillParent(true);
|
||||
cont.pane(e -> e.add(c.minfo.error)).grow();
|
||||
cont.row();
|
||||
cont.addImageTextButton("$ok", Icon.backSmall, this::hide).size(240f, 60f);
|
||||
}}.show();
|
||||
}).size(190f, 50f).left().marginLeft(6);
|
||||
d.row();
|
||||
}
|
||||
}).left();
|
||||
p.row();
|
||||
});
|
||||
});
|
||||
|
||||
cont.row();
|
||||
cont.addButton("$ok", this::hide).size(300, 50);
|
||||
}}.show();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasContentErrors(){
|
||||
return mods.contains(LoadedMod::hasContentErrors);
|
||||
}
|
||||
|
||||
/** Reloads all mod content. How does this even work? I refuse to believe that it functions correctly.*/
|
||||
public void reloadContent(){
|
||||
//epic memory leak
|
||||
//TODO make it less epic
|
||||
Core.atlas = new TextureAtlas(Core.files.internal("sprites/sprites.atlas"));
|
||||
|
||||
mods.clear();
|
||||
Core.bundle = I18NBundle.createBundle(Core.files.internal("bundles/bundle"), Core.bundle.getLocale());
|
||||
load();
|
||||
Sounds.dispose();
|
||||
Sounds.load();
|
||||
Core.assets.finishLoading();
|
||||
if(scripts != null){
|
||||
scripts.dispose();
|
||||
scripts = null;
|
||||
}
|
||||
content.clear();
|
||||
content.createBaseContent();
|
||||
content.loadColors();
|
||||
loadScripts();
|
||||
content.createModContent();
|
||||
loadAsync();
|
||||
loadSync();
|
||||
content.init();
|
||||
content.load();
|
||||
content.loadColors();
|
||||
data.load();
|
||||
Core.atlas.getTextures().each(t -> t.setFilter(Core.settings.getBool("linear") ? TextureFilter.Linear : TextureFilter.Nearest));
|
||||
requiresReload = false;
|
||||
|
||||
Events.fire(new ContentReloadEvent());
|
||||
}
|
||||
|
||||
/** This must be run on the main thread! */
|
||||
public void loadScripts(){
|
||||
Time.mark();
|
||||
|
||||
try{
|
||||
eachEnabled(mod -> {
|
||||
if(mod.root.child("scripts").exists()){
|
||||
content.setCurrentMod(mod);
|
||||
mod.scripts = mod.root.child("scripts").findAll(f -> f.extension().equals("js"));
|
||||
Log.debug("[{0}] Found {1} scripts.", mod.meta.name, mod.scripts.size);
|
||||
|
||||
for(Fi file : mod.scripts){
|
||||
try{
|
||||
if(scripts == null){
|
||||
scripts = platform.createScripts();
|
||||
}
|
||||
scripts.run(mod, file);
|
||||
}catch(Throwable e){
|
||||
Core.app.post(() -> {
|
||||
Log.err("Error loading script {0} for mod {1}.", file.name(), mod.meta.name);
|
||||
e.printStackTrace();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}finally{
|
||||
content.setCurrentMod(null);
|
||||
}
|
||||
|
||||
Log.debug("Time to initialize modded scripts: {0}", Time.elapsed());
|
||||
}
|
||||
|
||||
/** Creates all the content found in mod files. */
|
||||
public void loadContent(){
|
||||
|
||||
class LoadRun implements Comparable<LoadRun>{
|
||||
final ContentType type;
|
||||
final Fi file;
|
||||
final LoadedMod mod;
|
||||
|
||||
public LoadRun(ContentType type, Fi file, LoadedMod mod){
|
||||
this.type = type;
|
||||
this.file = file;
|
||||
this.mod = mod;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(LoadRun l){
|
||||
int mod = this.mod.name.compareTo(l.mod.name);
|
||||
if(mod != 0) return mod;
|
||||
return this.file.name().compareTo(l.file.name());
|
||||
}
|
||||
}
|
||||
|
||||
Array<LoadRun> runs = new Array<>();
|
||||
|
||||
for(LoadedMod mod : orderedMods()){
|
||||
if(mod.root.child("content").exists()){
|
||||
Fi contentRoot = mod.root.child("content");
|
||||
for(ContentType type : ContentType.all){
|
||||
Fi folder = contentRoot.child(type.name().toLowerCase() + "s");
|
||||
if(folder.exists()){
|
||||
for(Fi file : folder.list()){
|
||||
if(file.extension().equals("json") || file.extension().equals("hjson")){
|
||||
runs.add(new LoadRun(type, file, mod));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//make sure mod content is in proper order
|
||||
runs.sort();
|
||||
for(LoadRun l : runs){
|
||||
Content current = content.getLastAdded();
|
||||
try{
|
||||
//this binds the content but does not load it entirely
|
||||
Content loaded = parser.parse(l.mod, l.file.nameWithoutExtension(), l.file.readString("UTF-8"), l.file, l.type);
|
||||
Log.debug("[{0}] Loaded '{1}'.", l.mod.meta.name, (loaded instanceof UnlockableContent ? ((UnlockableContent)loaded).localizedName : loaded));
|
||||
}catch(Throwable e){
|
||||
if(current != content.getLastAdded() && content.getLastAdded() != null){
|
||||
parser.markError(content.getLastAdded(), l.mod, l.file, e);
|
||||
}else{
|
||||
ErrorContent error = new ErrorContent();
|
||||
parser.markError(error, l.mod, l.file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//this finishes parsing content fields
|
||||
parser.finishParsing();
|
||||
}
|
||||
|
||||
public void handleContentError(Content content, Throwable error){
|
||||
parser.markError(content, error);
|
||||
}
|
||||
|
||||
/** @return a list of mods and versions, in the format name:version. */
|
||||
public Array<String> getModStrings(){
|
||||
return mods.select(l -> !l.meta.hidden && l.enabled()).map(l -> l.name + ":" + l.meta.version);
|
||||
}
|
||||
|
||||
/** Makes a mod enabled or disabled. shifts it.*/
|
||||
public void setEnabled(LoadedMod mod, boolean enabled){
|
||||
if(mod.enabled() != enabled){
|
||||
Core.settings.putSave("mod-" + mod.name + "-enabled", enabled);
|
||||
requiresReload = true;
|
||||
mod.state = enabled ? ModState.enabled : ModState.disabled;
|
||||
mods.each(this::updateDependencies);
|
||||
sortMods();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return the mods that the client is missing.
|
||||
* The inputted array is changed to contain the extra mods that the client has but the server doesn't.*/
|
||||
public Array<String> getIncompatibility(Array<String> out){
|
||||
Array<String> mods = getModStrings();
|
||||
Array<String> result = mods.copy();
|
||||
for(String mod : mods){
|
||||
if(out.remove(mod)){
|
||||
result.remove(mod);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public Array<LoadedMod> list(){
|
||||
return mods;
|
||||
}
|
||||
|
||||
/** Iterates through each mod with a main class. */
|
||||
public void eachClass(Cons<Mod> cons){
|
||||
mods.each(p -> p.main != null, p -> contextRun(p, () -> cons.get(p.main)));
|
||||
}
|
||||
|
||||
/** Iterates through each enabled mod. */
|
||||
public void eachEnabled(Cons<LoadedMod> cons){
|
||||
mods.each(LoadedMod::enabled, cons);
|
||||
}
|
||||
|
||||
public void contextRun(LoadedMod mod, Runnable run){
|
||||
try{
|
||||
run.run();
|
||||
}catch(Throwable t){
|
||||
throw new RuntimeException("Error loading mod " + mod.meta.name, t);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads a mod file+meta, but does not add it to the list.
|
||||
* Note that directories can be loaded as mods.*/
|
||||
private LoadedMod loadMod(Fi sourceFile) throws Exception{
|
||||
Fi zip = sourceFile.isDirectory() ? sourceFile : new ZipFi(sourceFile);
|
||||
if(zip.list().length == 1 && zip.list()[0].isDirectory()){
|
||||
zip = zip.list()[0];
|
||||
}
|
||||
|
||||
Fi metaf = zip.child("mod.json").exists() ? zip.child("mod.json") : zip.child("mod.hjson").exists() ? zip.child("mod.hjson") : zip.child("plugin.json");
|
||||
if(!metaf.exists()){
|
||||
Log.warn("Mod {0} doesn't have a 'mod.json'/'plugin.json'/'mod.js' file, skipping.", sourceFile);
|
||||
throw new IllegalArgumentException("No mod.json found.");
|
||||
}
|
||||
|
||||
ModMeta meta = json.fromJson(ModMeta.class, Jval.read(metaf.readString()).toString(Jformat.plain));
|
||||
String camelized = meta.name.replace(" ", "");
|
||||
String mainClass = meta.main == null ? camelized.toLowerCase() + "." + camelized + "Mod" : meta.main;
|
||||
String baseName = meta.name.toLowerCase().replace(" ", "-");
|
||||
|
||||
if(mods.contains(m -> m.name.equals(baseName))){
|
||||
throw new IllegalArgumentException("A mod with the name '" + baseName + "' is already imported.");
|
||||
}
|
||||
|
||||
Mod mainMod;
|
||||
|
||||
Fi mainFile = zip;
|
||||
String[] path = (mainClass.replace('.', '/') + ".class").split("/");
|
||||
for(String str : path){
|
||||
if(!str.isEmpty()){
|
||||
mainFile = mainFile.child(str);
|
||||
}
|
||||
}
|
||||
|
||||
//make sure the main class exists before loading it; if it doesn't just don't put it there
|
||||
if(mainFile.exists()){
|
||||
//other platforms don't have standard java class loaders
|
||||
if(!headless && Version.build != -1){
|
||||
throw new IllegalArgumentException("Java class mods are currently unsupported outside of custom builds.");
|
||||
}
|
||||
|
||||
URLClassLoader classLoader = new URLClassLoader(new URL[]{sourceFile.file().toURI().toURL()}, ClassLoader.getSystemClassLoader());
|
||||
Class<?> main = classLoader.loadClass(mainClass);
|
||||
metas.put(main, meta);
|
||||
mainMod = (Mod)main.getDeclaredConstructor().newInstance();
|
||||
}else{
|
||||
mainMod = null;
|
||||
}
|
||||
|
||||
//all plugins are hidden implicitly
|
||||
if(mainMod instanceof Plugin){
|
||||
meta.hidden = true;
|
||||
}
|
||||
|
||||
return new LoadedMod(sourceFile, zip, mainMod, meta);
|
||||
}
|
||||
|
||||
/** Represents a plugin that has been loaded from a jar file.*/
|
||||
public static class LoadedMod implements Publishable{
|
||||
/** The location of this mod's zip file/folder on the disk. */
|
||||
public final Fi file;
|
||||
/** The root zip file; points to the contents of this mod. In the case of folders, this is the same as the mod's file. */
|
||||
public final Fi root;
|
||||
/** The mod's main class; may be null. */
|
||||
public final @Nullable Mod main;
|
||||
/** Internal mod name. Used for textures. */
|
||||
public final String name;
|
||||
/** This mod's metadata. */
|
||||
public final ModMeta meta;
|
||||
/** This mod's dependencies as already-loaded mods. */
|
||||
public Array<LoadedMod> dependencies = new Array<>();
|
||||
/** All missing dependencies of this mod as strings. */
|
||||
public Array<String> missingDependencies = new Array<>();
|
||||
/** Script files to run. */
|
||||
public Array<Fi> scripts = new Array<>();
|
||||
/** Content with intialization code. */
|
||||
public ObjectSet<Content> erroredContent = new ObjectSet<>();
|
||||
/** Current state of this mod. */
|
||||
public ModState state = ModState.enabled;
|
||||
|
||||
public LoadedMod(Fi file, Fi root, Mod main, ModMeta meta){
|
||||
this.root = root;
|
||||
this.file = file;
|
||||
this.main = main;
|
||||
this.meta = meta;
|
||||
this.name = meta.name.toLowerCase().replace(" ", "-");
|
||||
}
|
||||
|
||||
public boolean enabled(){
|
||||
return state == ModState.enabled || state == ModState.contentErrors;
|
||||
}
|
||||
|
||||
public boolean shouldBeEnabled(){
|
||||
return Core.settings.getBool("mod-" + name + "-enabled", true);
|
||||
}
|
||||
|
||||
public boolean hasUnmetDependencies(){
|
||||
return !missingDependencies.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasContentErrors(){
|
||||
return !erroredContent.isEmpty();
|
||||
}
|
||||
|
||||
/** @return whether this mod is supported by the game verison */
|
||||
public boolean isSupported(){
|
||||
if(Version.build <= 0 || meta.minGameVersion == null) return true;
|
||||
if(meta.minGameVersion.contains(".")){
|
||||
String[] split = meta.minGameVersion.split("\\.");
|
||||
if(split.length == 2){
|
||||
return Version.build >= Strings.parseInt(split[0], 0) && Version.revision >= Strings.parseInt(split[1], 0);
|
||||
}
|
||||
}
|
||||
return Version.build >= Strings.parseInt(meta.minGameVersion, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSteamID(){
|
||||
return Core.settings.getString(name + "-steamid", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addSteamID(String id){
|
||||
Core.settings.put(name + "-steamid", id);
|
||||
Core.settings.save();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeSteamID(){
|
||||
Core.settings.remove(name + "-steamid");
|
||||
Core.settings.save();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String steamTitle(){
|
||||
return meta.name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String steamDescription(){
|
||||
return meta.description;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String steamTag(){
|
||||
return "mod";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Fi createSteamFolder(String id){
|
||||
return file;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Fi createSteamPreview(String id){
|
||||
return file.child("preview.png");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean prePublish(){
|
||||
if(!file.isDirectory()){
|
||||
ui.showErrorMessage("$mod.folder.missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!file.child("preview.png").exists()){
|
||||
ui.showErrorMessage("$mod.preview.missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(){
|
||||
return "LoadedMod{" +
|
||||
"file=" + file +
|
||||
", root=" + root +
|
||||
", name='" + name + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
/** Plugin metadata information.*/
|
||||
public static class ModMeta{
|
||||
public String name, displayName, author, description, version, main, minGameVersion;
|
||||
public Array<String> dependencies = Array.with();
|
||||
/** Hidden mods are only server-side or client-side, and do not support adding new content. */
|
||||
public boolean hidden;
|
||||
|
||||
public String displayName(){
|
||||
return displayName == null ? name : displayName;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ModState{
|
||||
enabled,
|
||||
contentErrors,
|
||||
missingDependencies,
|
||||
unsupported,
|
||||
disabled,
|
||||
}
|
||||
}
|
||||
84
core/src/mindustry/mod/Scripts.java
Normal file
84
core/src/mindustry/mod/Scripts.java
Normal file
@@ -0,0 +1,84 @@
|
||||
package mindustry.mod;
|
||||
|
||||
import arc.*;
|
||||
import arc.files.*;
|
||||
import arc.util.*;
|
||||
import arc.util.Log.*;
|
||||
import mindustry.*;
|
||||
import mindustry.mod.Mods.*;
|
||||
import org.mozilla.javascript.*;
|
||||
|
||||
public class Scripts implements Disposable{
|
||||
private final Context context;
|
||||
private final String wrapper;
|
||||
private Scriptable scope;
|
||||
private boolean errored;
|
||||
|
||||
public Scripts(){
|
||||
Time.mark();
|
||||
|
||||
context = Vars.platform.getScriptContext();
|
||||
context.setClassShutter(type -> (ClassAccess.allowedClassNames.contains(type) || type.startsWith("$Proxy") ||
|
||||
type.startsWith("adapter") || type.contains("PrintStream") ||
|
||||
type.startsWith("io.anuke.mindustry")) && !type.equals("mindustry.mod.ClassAccess"));
|
||||
|
||||
scope = new ImporterTopLevel(context);
|
||||
wrapper = Core.files.internal("scripts/wrapper.js").readString();
|
||||
|
||||
if(!run(Core.files.internal("scripts/global.js").readString(), "global.js")){
|
||||
errored = true;
|
||||
}
|
||||
Log.debug("Time to load script engine: {0}", Time.elapsed());
|
||||
}
|
||||
|
||||
public boolean hasErrored(){
|
||||
return errored;
|
||||
}
|
||||
|
||||
public String runConsole(String text){
|
||||
try{
|
||||
Object o = context.evaluateString(scope, text, "console.js", 1, null);
|
||||
if(o instanceof NativeJavaObject){
|
||||
o = ((NativeJavaObject)o).unwrap();
|
||||
}
|
||||
if(o instanceof Undefined){
|
||||
o = "undefined";
|
||||
}
|
||||
return String.valueOf(o);
|
||||
}catch(Throwable t){
|
||||
return getError(t);
|
||||
}
|
||||
}
|
||||
|
||||
private String getError(Throwable t){
|
||||
t.printStackTrace();
|
||||
return t.getClass().getSimpleName() + (t.getMessage() == null ? "" : ": " + t.getMessage());
|
||||
}
|
||||
|
||||
public void log(String source, String message){
|
||||
log(LogLevel.info, source, message);
|
||||
}
|
||||
|
||||
public void log(LogLevel level, String source, String message){
|
||||
Log.log(level, "[{0}]: {1}", source, message);
|
||||
}
|
||||
|
||||
public void run(LoadedMod mod, Fi file){
|
||||
run(wrapper.replace("$SCRIPT_NAME$", mod.name + "/" + file.nameWithoutExtension()).replace("$CODE$", file.readString()).replace("$MOD_NAME$", mod.name), file.name());
|
||||
}
|
||||
|
||||
private boolean run(String script, String file){
|
||||
try{
|
||||
context.evaluateString(scope, script, file, 1, null);
|
||||
return true;
|
||||
}catch(Throwable t){
|
||||
log(LogLevel.err, file, "" + getError(t));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose(){
|
||||
Context.exit();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user