Merge branch 'master' into port-field

This commit is contained in:
Anuken
2025-02-08 19:36:26 -05:00
committed by GitHub
2681 changed files with 151338 additions and 45271 deletions
+127 -25
View File
@@ -4,11 +4,11 @@ import arc.*;
import arc.assets.*;
import arc.assets.loaders.*;
import arc.audio.*;
import arc.files.*;
import arc.graphics.*;
import arc.graphics.g2d.*;
import arc.math.*;
import arc.util.*;
import arc.util.async.*;
import mindustry.ai.*;
import mindustry.core.*;
import mindustry.ctype.*;
@@ -18,7 +18,7 @@ import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.maps.*;
import mindustry.mod.*;
import mindustry.net.Net;
import mindustry.net.*;
import mindustry.ui.*;
import static arc.Core.*;
@@ -27,13 +27,20 @@ import static mindustry.Vars.*;
public abstract class ClientLauncher extends ApplicationCore implements Platform{
private static final int loadingFPS = 20;
private long lastTime;
private long nextFrame;
private long beginTime;
private long lastTargetFps = -1;
private boolean finished = false;
private LoadRenderer loader;
@Override
public void setup(){
String dataDir = System.getProperty("mindustry.data.dir", OS.env("MINDUSTRY_DATA_DIR"));
if(dataDir != null){
Core.settings.setDataDirectory(files.absolute(dataDir));
}
checkLaunch();
loadLogger();
loader = new LoadRenderer();
@@ -41,30 +48,101 @@ public abstract class ClientLauncher extends ApplicationCore implements Platform
loadFileLogger();
platform = this;
maxTextureSize = Gl.getInt(Gl.maxTextureSize);
beginTime = Time.millis();
//debug GL information
Log.info("[GL] Version: @", graphics.getGLVersion());
Log.info("[GL] Max texture size: @", Gl.getInt(Gl.maxTextureSize));
Log.info("[GL] Max texture size: @", maxTextureSize);
Log.info("[GL] Using @ context.", gl30 != null ? "OpenGL 3" : "OpenGL 2");
Log.info("[JAVA] Version: @", System.getProperty("java.version"));
if(NvGpuInfo.hasMemoryInfo()){
Log.info("[GL] Total available VRAM: @mb", NvGpuInfo.getMaxMemoryKB()/1024);
}
if(maxTextureSize < 4096) Log.warn("[GL] Your maximum texture size is below the recommended minimum of 4096. This will cause severe performance issues.");
Log.info("[JAVA] Version: @", OS.javaVersion);
if(Core.app.isAndroid()){
Log.info("[ANDROID] API level: @", Core.app.getVersion());
}
long ram = Runtime.getRuntime().maxMemory();
boolean gb = ram >= 1024 * 1024 * 1024;
if(!OS.isIos){
Log.info("[RAM] Available: @ @", Strings.fixed(gb ? ram / 1024f / 1024 / 1024f : ram / 1024f / 1024f, 1), gb ? "GB" : "MB");
}
Time.setDeltaProvider(() -> {
float result = Core.graphics.getDeltaTime() * 60f;
return (Float.isNaN(result) || Float.isInfinite(result)) ? 1f : Mathf.clamp(result, 0.0001f, 60f / 10f);
return (Float.isNaN(result) || Float.isInfinite(result)) ? 1f : Mathf.clamp(result, 0.0001f, maxDeltaClient);
});
batch = new SortedSpriteBatch();
UI.loadColors();
batch = new SpriteBatch();
assets = new AssetManager();
assets.setLoader(Texture.class, "." + mapExtension, new MapPreviewLoader());
tree = new FileTree();
assets.setLoader(Sound.class, new SoundLoader(tree));
assets.setLoader(Music.class, new MusicLoader(tree));
assets.setLoader(Sound.class, new SoundLoader(tree){
@Override
public void loadAsync(AssetManager manager, String fileName, Fi file, SoundParameter parameter){
}
@Override
public Sound loadSync(AssetManager manager, String fileName, Fi file, SoundParameter parameter){
if(parameter != null && parameter.sound != null){
mainExecutor.submit(() -> parameter.sound.load(file));
return parameter.sound;
}else{
Sound sound = new Sound();
mainExecutor.submit(() -> {
try{
sound.load(file);
}catch(Throwable t){
Log.err("Error loading sound: " + file, t);
}
});
return sound;
}
}
});
assets.setLoader(Music.class, new MusicLoader(tree){
@Override
public void loadAsync(AssetManager manager, String fileName, Fi file, MusicParameter parameter){}
@Override
public Music loadSync(AssetManager manager, String fileName, Fi file, MusicParameter parameter){
if(parameter != null && parameter.music != null){
mainExecutor.submit(() -> {
try{
parameter.music.load(file);
}catch(Throwable t){
Log.err("Error loading music: " + file, t);
}
});
return parameter.music;
}else{
Music music = new Music();
mainExecutor.submit(() -> {
try{
music.load(file);
}catch(Throwable t){
Log.err("Error loading music: " + file, t);
}
});
return music;
}
}
});
assets.load("sprites/error.png", Texture.class);
atlas = TextureAtlas.blankAtlas();
Vars.net = new Net(platform.getNet());
MapPreviewLoader.setupLoaders();
mods = new Mods();
schematics = new Schematics();
@@ -75,11 +153,7 @@ public abstract class ClientLauncher extends ApplicationCore implements Platform
Fonts.loadDefaultFont();
//load fallback atlas if max texture size is below 4096
assets.load(new AssetDescriptor<>(Gl.getInt(Gl.maxTextureSize) >= 4096 ? "sprites/sprites.atlas" : "sprites/fallback/sprites.atlas", TextureAtlas.class)).loaded = t -> {
atlas = (TextureAtlas)t;
Fonts.mergeFontAtlas(atlas);
};
assets.load(new AssetDescriptor<>(maxTextureSize >= 4096 ? "sprites/sprites.aatls" : "sprites/fallback/sprites.aatls", TextureAtlas.class)).loaded = t -> atlas = t;
assets.loadRun("maps", Map.class, () -> maps.loadPreviews());
Musics.load();
@@ -93,6 +167,9 @@ public abstract class ClientLauncher extends ApplicationCore implements Platform
content.createModContent();
});
assets.load(mods);
assets.loadRun("mergeUI", PixmapPacker.class, () -> {}, () -> Fonts.mergeFontAtlas(atlas));
add(logic = new Logic());
add(control = new Control());
add(renderer = new Renderer());
@@ -100,7 +177,6 @@ public abstract class ClientLauncher extends ApplicationCore implements Platform
add(netServer = new NetServer());
add(netClient = new NetClient());
assets.load(mods);
assets.load(schematics);
assets.loadRun("contentinit", ContentLoader.class, () -> content.init(), () -> content.load());
@@ -130,6 +206,18 @@ public abstract class ClientLauncher extends ApplicationCore implements Platform
@Override
public void update(){
int targetfps = Core.settings.getInt("fpscap", 120);
boolean changed = lastTargetFps != targetfps && lastTargetFps != -1;
boolean limitFps = targetfps > 0 && targetfps <= 240;
lastTargetFps = targetfps;
if(limitFps && !changed){
nextFrame += (1000 * 1000000) / targetfps;
}else{
nextFrame = Time.nanos();
}
if(!finished){
if(loader != null){
loader.draw();
@@ -137,15 +225,21 @@ public abstract class ClientLauncher extends ApplicationCore implements Platform
if(assets.update(1000 / loadingFPS)){
loader.dispose();
loader = null;
Log.info("Total time to load: @", Time.timeSinceMillis(beginTime));
Log.info("Total time to load: @ms", Time.timeSinceMillis(beginTime));
for(ApplicationListener listener : modules){
listener.init();
}
mods.eachClass(Mod::init);
finished = true;
Events.fire(new ClientLoadEvent());
clientLoaded = true;
super.resize(graphics.getWidth(), graphics.getHeight());
app.post(() -> app.post(() -> app.post(() -> app.post(() -> super.resize(graphics.getWidth(), graphics.getHeight())))));
app.post(() -> app.post(() -> app.post(() -> app.post(() -> {
super.resize(graphics.getWidth(), graphics.getHeight());
//mark initialization as complete
finishLaunch();
}))));
}
}else{
asyncCore.begin();
@@ -155,21 +249,24 @@ public abstract class ClientLauncher extends ApplicationCore implements Platform
asyncCore.end();
}
int targetfps = Core.settings.getInt("fpscap", 120);
if(targetfps > 0 && targetfps <= 240){
long target = (1000 * 1000000) / targetfps; //target in nanos
long elapsed = Time.timeSinceNanos(lastTime);
if(elapsed < target){
Threads.sleep((target - elapsed) / 1000000, (int)((target - elapsed) % 1000000));
if(limitFps){
long current = Time.nanos();
if(nextFrame > current){
long toSleep = nextFrame - current;
Threads.sleep(toSleep / 1000000, (int)(toSleep % 1000000));
}
}
}
lastTime = Time.nanos();
@Override
public void exit(){
//on graceful exit, finish the launch normally.
Vars.finishLaunch();
}
@Override
public void init(){
nextFrame = Time.nanos();
setup();
}
@@ -182,6 +279,11 @@ public abstract class ClientLauncher extends ApplicationCore implements Platform
@Override
public void pause(){
//when the user tabs out on mobile, the exit() event doesn't fire reliably - in that case, just assume they're about to kill the app
//this isn't 100% reliable but it should work for most cases
if(mobile){
Vars.finishLaunch();
}
if(finished){
super.pause();
}
+153 -53
View File
@@ -11,37 +11,52 @@ import arc.util.Log.*;
import mindustry.ai.*;
import mindustry.async.*;
import mindustry.core.*;
import mindustry.ctype.*;
import mindustry.editor.*;
import mindustry.entities.*;
import mindustry.game.EventType.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.input.*;
import mindustry.io.*;
import mindustry.logic.*;
import mindustry.maps.Map;
import mindustry.maps.*;
import mindustry.mod.*;
import mindustry.net.Net;
import mindustry.net.*;
import mindustry.service.*;
import mindustry.ui.dialogs.*;
import mindustry.world.*;
import mindustry.world.blocks.storage.*;
import mindustry.world.meta.*;
import java.io.*;
import java.nio.charset.*;
import java.util.*;
import java.util.concurrent.*;
import static arc.Core.*;
public class Vars implements Loadable{
/** Whether the game failed to launch last time. */
public static boolean failedToLaunch = false;
/** Whether to load locales.*/
public static boolean loadLocales = true;
/** Whether the logger is loaded. */
public static boolean loadedLogger = false, loadedFileLogger = false;
/** Whether to enable various experimental features (e.g. cliffs) */
public static boolean experimental = false;
/** Name of current Steam player. */
public static String steamPlayerName = "";
/** If true, the BE server list is always used. */
public static boolean forceBeServers = false;
/** Default accessible content types used for player-selectable icons. */
public static final ContentType[] defaultContentIcons = {ContentType.item, ContentType.liquid, ContentType.block, ContentType.unit};
/** Default rule environment. */
public static final int defaultEnv = Env.terrestrial | Env.spores | Env.groundOil | Env.groundWater | Env.oxygen;
/** Wall darkness radius. */
public static final int darkRadius = 4;
/** Maximum extra padding around deployment schematics. */
public static final int maxLoadoutSchematicPad = 5;
/** Maximum schematic size.*/
public static final int maxSchematicSize = 32;
/** All schematic base64 starts with this string.*/
public static final String schematicBaseStart ="bXNjaA";
/** IO buffer size. */
@@ -50,20 +65,18 @@ public class Vars implements Loadable{
public static final Charset charset = Charset.forName("UTF-8");
/** main application name, capitalized */
public static final String appName = "Mindustry";
/** URL for itch.io donations. */
public static final String donationURL = "https://anuke.itch.io/mindustry/purchase";
/** Github API URL. */
public static final String ghApi = "https://api.github.com";
/** URL for discord invite. */
public static final String discordURL = "https://discord.gg/mindustry";
/** URL for sending crash reports to */
public static final String crashReportURL = "http://192.99.169.18/report";
/** URL the links to the wiki's modding guide.*/
public static final String modGuideURL = "https://mindustrygame.github.io/wiki/modding/1-modding/";
/** URL to the JSON file containing all the global, public servers. Not queried in BE. */
public static final String serverJsonURL = "https://raw.githubusercontent.com/Anuken/Mindustry/master/servers.json";
/** URL to the JSON file containing all the BE servers. Only queried in BE. */
public static final String serverJsonBeURL = "https://raw.githubusercontent.com/Anuken/Mindustry/master/servers_be.json";
/** URL to the JSON file containing all the BE servers. Only queried in the V6 alpha (will be removed once it's out). */
public static final String serverJsonV6URL = "https://raw.githubusercontent.com/Anuken/Mindustry/master/servers_v6.json";
/** URLs to the JSON file containing all the BE servers. Only queried in BE. */
public static final String[] serverJsonBeURLs = {"https://raw.githubusercontent.com/Anuken/MindustryServerList/master/servers_be.json", "https://cdn.jsdelivr.net/gh/anuken/mindustryserverlist/servers_be.json"};
/** URLs to the JSON file containing all the stable servers. */
public static final String[] serverJsonURLs = {"https://raw.githubusercontent.com/Anuken/MindustryServerList/master/servers_v8.json", "https://cdn.jsdelivr.net/gh/anuken/mindustryserverlist/servers_v8.json"};
/** URLs to the JSON files containing the list of mods. */
public static final String[] modJsonURLs = {"https://raw.githubusercontent.com/Anuken/MindustryMods/master/mods.json", "https://cdn.jsdelivr.net/gh/anuken/mindustrymods/mods.json"};
/** URL of the github issue report template.*/
public static final String reportIssueURL = "https://github.com/Anuken/Mindustry/issues/new?labels=bug&template=bug_report.md";
/** list of built-in servers.*/
@@ -78,12 +91,12 @@ public class Vars implements Loadable{
public static final int maxNameLength = 40;
/** displayed item size when ingame. */
public static final float itemSize = 5f;
/** units outside of this bound will die instantly */
public static final float finalWorldBounds = 500;
/** mining range for manual miners */
public static final float miningRange = 70f;
/** range for building */
/** units outside this bound will die instantly */
public static final float finalWorldBounds = 250;
/** default range for building */
public static final float buildingRange = 220f;
/** scaling for unit circle collider radius, based on hitbox size */
public static final float unitCollisionRadiusScale = 0.6f;
/** range for moving items */
public static final float itemTransferRange = 220f;
/** range for moving items for logic units */
@@ -92,18 +105,20 @@ public class Vars implements Loadable{
public static final float turnDuration = 2 * Time.toMinutes;
/** chance of an invasion per turn, 1 = 100% */
public static final float baseInvasionChance = 1f / 100f;
/** how many turns have to pass before invasions start */
public static final int invasionGracePeriod = 20;
/** how many minutes have to pass before invasions in a *captured* sector start */
public static final float invasionGracePeriod = 20;
/** min armor fraction damage; e.g. 0.05 = at least 5% damage */
public static final float minArmorDamage = 0.1f;
/** launch animation duration */
public static final float launchDuration = 140f;
/** @deprecated see {@link CoreBlock#landDuration} instead! */
public static final @Deprecated float coreLandDuration = 160f;
/** size of tiles in units */
public static final int tilesize = 8;
/** size of one tile payload (^2) */
public static final float tilePayload = tilesize * tilesize;
/** tile used in certain situations, instead of null */
public static Tile emptyTile;
/** icon sizes for UI */
public static final float iconXLarge = 8*6f, iconLarge = 8*5f, iconMed = 8*4f, iconSmall = 8*3f;
/** macbook screen notch height */
public static float macNotchHeight = 32f;
/** for map generator dialog */
public static boolean updateEditorOnChange = false;
/** all choosable player colors in join/host dialog */
@@ -125,14 +140,41 @@ public class Vars implements Loadable{
Color.valueOf("4b5ef1"),
Color.valueOf("2cabfe"),
};
/** Icons available to the user for customization in certain dialogs. */
public static final String[] accessibleIcons = {
"effect", "power", "logic", "units", "liquid", "production", "defense", "turret", "distribution", "crafting",
"settings", "cancel", "zoom", "ok", "star", "home", "pencil", "up", "down", "left", "right",
"hammer", "warning", "tree", "admin", "map", "modePvp", "terrain",
"modeSurvival", "commandRally", "commandAttack",
};
/** maximum TCP packet size */
public static final int maxTcpSize = 1100;
/** default server port */
public static final int port = 6567;
/** multicast discovery port.*/
public static final int multicastPort = 20151;
/** Maximum char length of mod subtitles in browser/viewer. */
public static final int maxModSubtitleLength = 40;
/** multicast group for discovery.*/
public static final String multicastGroup = "227.2.7.7";
/** Maximum delta time. If the actual delta time (*60) between frames is higher than this number, the game will start to slow down. */
public static float maxDeltaClient = 6f, maxDeltaServer = 10f;
/** whether the graphical game client has loaded */
public static boolean clientLoaded = false;
/** max GL texture size */
public static int maxTextureSize = 2048;
/** Maximum schematic size.*/
public static int maxSchematicSize = 64;
/** Whether to show sector info upon landing. */
public static boolean showSectorLandInfo = true;
/** Whether to check for memory use before taking screenshots. */
public static boolean checkScreenshotMemory = true;
/** Whether to prompt the user to confirm exiting. */
public static boolean confirmExit = true;
/** if true, UI is not drawn */
public static boolean disableUI;
/** if true, most autosaving is disabled. internal use only! */
public static boolean disableSave;
/** if true, game is set up in mobile mode, even on desktop. used for debugging */
public static boolean testMobile;
/** whether the game is running on a mobile device */
@@ -145,8 +187,6 @@ public class Vars implements Loadable{
public static boolean headless;
/** whether steam is enabled for this game */
public static boolean steam;
/** whether typing into the console is enabled - developers only */
public static boolean enableConsole = false;
/** whether to clear sector saves when landing */
public static boolean clearSectors = false;
/** whether any light rendering is enabled */
@@ -172,18 +212,27 @@ public class Vars implements Loadable{
public static Fi schematicDirectory;
/** data subdirectory used for bleeding edge build versions */
public static Fi bebuildDirectory;
/** file used to store launch ID */
public static Fi launchIDFile;
/** empty map, indicates no current map */
public static Map emptyMap;
/** empty tile for payloads */
public static Tile emptyTile;
/** map file extension */
public static final String mapExtension = "msav";
/** save file extension */
public static final String saveExtension = "msav";
/** schematic file extension */
public static final String schematicExtension = "msch";
/** path to the java executable */
public static String javaPath;
/** list of all locales that can be switched to */
public static Locale[] locales;
//the main executor will only have at most [cores] number of threads active
public static ExecutorService mainExecutor = Threads.executor("Main Executor", OS.cores);
public static FileTree tree = new FileTree();
public static Net net;
public static ContentLoader content;
@@ -196,7 +245,9 @@ public class Vars implements Loadable{
public static BeControl becontrol;
public static AsyncCore asyncCore;
public static BaseRegistry bases;
public static GlobalConstants constants;
public static GlobalVars logicVars;
public static MapEditor editor;
public static GameService service = new GameService();
public static Universe universe;
public static World world;
@@ -204,6 +255,8 @@ public class Vars implements Loadable{
public static WaveSpawner spawner;
public static BlockIndexer indexer;
public static Pathfinder pathfinder;
public static ControlPathfinder controlPath;
public static FogControl fogControl;
public static Control control;
public static Logic logic;
@@ -236,11 +289,16 @@ public class Vars implements Loadable{
}
}
Arrays.sort(locales, Structs.comparing(l -> l.getDisplayName(l), String.CASE_INSENSITIVE_ORDER));
locales = Seq.with(locales).and(new Locale("router")).toArray(Locale.class);
Arrays.sort(locales, Structs.comparing(LanguageDialog::getDisplayName, String.CASE_INSENSITIVE_ORDER));
locales = Seq.with(locales).add(new Locale("router")).toArray(Locale.class);
}
Version.init();
CacheLayer.init();
if(!headless){
Log.info("[Mindustry] Version: @", Version.buildString());
}
dataDirectory = settings.getDataDirectory();
screenshotDirectory = dataDirectory.child("screenshots/");
@@ -252,7 +310,6 @@ public class Vars implements Loadable{
schematicDirectory = dataDirectory.child("schematics/");
bebuildDirectory = dataDirectory.child("be_builds/");
emptyMap = new Map(new StringMap());
emptyTile = null;
if(tree == null) tree = new FileTree();
if(mods == null) mods = new Mods();
@@ -264,13 +321,21 @@ public class Vars implements Loadable{
universe = new Universe();
becontrol = new BeControl();
asyncCore = new AsyncCore();
if(!headless) editor = new MapEditor();
maps = new Maps();
spawner = new WaveSpawner();
indexer = new BlockIndexer();
pathfinder = new Pathfinder();
controlPath = new ControlPathfinder();
fogControl = new FogControl();
bases = new BaseRegistry();
constants = new GlobalConstants();
logicVars = new GlobalVars();
javaPath =
new Fi(OS.prop("java.home")).child("bin/java").exists() ? new Fi(OS.prop("java.home")).child("bin/java").absolutePath() :
Core.files.local("jre/bin/java").exists() ? Core.files.local("jre/bin/java").absolutePath() : // Unix
Core.files.local("jre/bin/java.exe").exists() ? Core.files.local("jre/bin/java.exe").absolutePath() : // Windows
"java";
state = new GameState();
@@ -280,10 +345,35 @@ public class Vars implements Loadable{
modDirectory.mkdirs();
Events.on(ContentInitEvent.class, e -> {
emptyTile = new Tile(Short.MAX_VALUE - 20, Short.MAX_VALUE - 20);
});
mods.load();
maps.load();
}
/** Checks if a launch failure occurred.
* If this is the case, failedToLaunch is set to true. */
public static void checkLaunch(){
settings.setAppName(appName);
launchIDFile = settings.getDataDirectory().child("launchid.dat");
if(launchIDFile.exists()){
failedToLaunch = true;
}else{
failedToLaunch = false;
launchIDFile.writeString("go away");
}
}
/** Cleans up after a successful launch. */
public static void finishLaunch(){
if(launchIDFile != null){
launchIDFile.delete();
}
}
public static void loadLogger(){
if(loadedLogger) return;
@@ -292,26 +382,28 @@ public class Vars implements Loadable{
Seq<String> logBuffer = new Seq<>();
Log.logger = (level, text) -> {
String result = text;
String rawText = Log.format(stags[level.ordinal()] + "&fr " + text);
System.out.println(rawText);
synchronized(logBuffer){
String result = text;
String rawText = Log.format(stags[level.ordinal()] + "&fr " + text);
System.out.println(rawText);
result = tags[level.ordinal()] + " " + result;
result = tags[level.ordinal()] + " " + result;
if(!headless && (ui == null || ui.scriptfrag == null)){
logBuffer.add(result);
}else if(!headless){
if(!OS.isWindows){
for(String code : ColorCodes.values){
result = result.replace(code, "");
if(!headless && (ui == null || ui.consolefrag == null)){
logBuffer.add(result);
}else if(!headless){
if(!OS.isWindows){
for(String code : ColorCodes.values){
result = result.replace(code, "");
}
}
}
ui.scriptfrag.addMessage(Log.removeColors(result));
ui.consolefrag.addMessage(Log.removeColors(result));
}
}
};
Events.on(ClientLoadEvent.class, e -> logBuffer.each(ui.scriptfrag::addMessage));
Events.on(ClientLoadEvent.class, e -> logBuffer.each(ui.consolefrag::addMessage));
loadedLogger = true;
}
@@ -324,12 +416,11 @@ public class Vars implements Loadable{
try{
Writer writer = settings.getDataDirectory().child("last_log.txt").writer(false);
LogHandler log = Log.logger;
//ignore it
Log.logger = (level, text) -> {
log.log(level, text);
try{
writer.write("[" + Character.toUpperCase(level.name().charAt(0)) +"] " + Log.removeColors(text) + "\n");
writer.write("[" + Character.toUpperCase(level.name().charAt(0)) + "] " + Log.removeColors(text) + "\n");
writer.flush();
}catch(IOException e){
e.printStackTrace();
@@ -345,7 +436,7 @@ public class Vars implements Loadable{
}
public static void loadSettings(){
settings.setJson(JsonIO.json());
settings.setJson(JsonIO.json);
settings.setAppName(appName);
if(steam || (Version.modifier != null && Version.modifier.contains("steam"))){
@@ -357,7 +448,12 @@ public class Vars implements Loadable{
settings.setAutosave(false);
settings.load();
Scl.setProduct(settings.getInt("uiscale", 100) / 100f);
//https://github.com/Anuken/Mindustry/issues/8483
if(settings.getInt("uiscale") == 5){
settings.put("uiscale", 100);
}
Scl.setProduct(Math.max(settings.getInt("uiscale", 100), 25) / 100f);
if(!loadLocales) return;
@@ -371,7 +467,7 @@ public class Vars implements Loadable{
Log.info("NOTE: external translation bundle has been loaded.");
if(!headless){
Time.run(10f, () -> ui.showInfo("Note: You have successfully loaded an external translation bundle."));
Time.run(10f, () -> ui.showInfo("Note: You have successfully loaded an external translation bundle.\n[accent]" + handle.absolutePath()));
}
}catch(Throwable e){
//no external bundle found
@@ -397,8 +493,12 @@ public class Vars implements Loadable{
Core.bundle = I18NBundle.createBundle(handle, locale);
//router
if(locale.getDisplayName().equals("router")){
bundle.debug("router");
if(locale.toString().equals("router")){
I18NBundle defBundle = I18NBundle.createBundle(Core.files.internal("bundles/bundle"));
String router = Character.toString(Iconc.blockRouter);
for(String s : bundle.getKeys()){
bundle.getProperties().put(s, Strings.stripColors(defBundle.get(s)).replaceAll("\\S", router));
}
}
}
}
+16 -9
View File
@@ -6,6 +6,8 @@ import arc.struct.*;
import arc.util.*;
import mindustry.world.*;
import java.util.*;
import static mindustry.Vars.*;
public class Astar{
@@ -13,18 +15,18 @@ public class Astar{
private static final Seq<Tile> out = new Seq<>();
private static final PQueue<Tile> queue = new PQueue<>(200 * 200 / 4, (a, b) -> 0);
private static final IntFloatMap costs = new IntFloatMap();
private static float[] costs;
private static byte[][] rotations;
public static Seq<Tile> pathfind(Tile from, Tile to, TileHueristic th, Boolf<Tile> passable){
public static Seq<Tile> pathfind(Tile from, Tile to, TileHeuristic th, Boolf<Tile> passable){
return pathfind(from.x, from.y, to.x, to.y, th, manhattan, passable);
}
public static Seq<Tile> pathfind(int startX, int startY, int endX, int endY, TileHueristic th, Boolf<Tile> passable){
public static Seq<Tile> pathfind(int startX, int startY, int endX, int endY, TileHeuristic th, Boolf<Tile> passable){
return pathfind(startX, startY, endX, endY, th, manhattan, passable);
}
public static Seq<Tile> pathfind(int startX, int startY, int endX, int endY, TileHueristic th, DistanceHeuristic dh, Boolf<Tile> passable){
public static Seq<Tile> pathfind(int startX, int startY, int endX, int endY, TileHeuristic th, DistanceHeuristic dh, Boolf<Tile> passable){
Tiles tiles = world.tiles;
Tile start = tiles.getn(startX, startY);
@@ -32,9 +34,14 @@ public class Astar{
GridBits closed = new GridBits(tiles.width, tiles.height);
costs.clear();
if(costs == null || costs.length != tiles.width * tiles.height){
costs = new float[tiles.width * tiles.height];
}
Arrays.fill(costs, 0);
queue.clear();
queue.comparator = Structs.comparingFloat(a -> costs.get(a.pos(), 0f) + dh.cost(a.x, a.y, end.x, end.y));
queue.comparator = Structs.comparingFloat(a -> costs[a.array()] + dh.cost(a.x, a.y, end.x, end.y));
queue.add(start);
if(rotations == null || rotations.length != world.width() || rotations[0].length != world.height()){
rotations = new byte[world.width()][world.height()];
@@ -43,7 +50,7 @@ public class Astar{
boolean found = false;
while(!queue.empty()){
Tile next = queue.poll();
float baseCost = costs.get(next.pos(), 0f);
float baseCost = costs[next.array()];
if(next == end){
found = true;
break;
@@ -58,7 +65,7 @@ public class Astar{
if(!closed.get(child.x, child.y)){
closed.set(child.x, child.y);
rotations[child.x][child.y] = child.relativeTo(next.x, next.y);
costs.put(child.pos(), newCost);
costs[child.array()] = newCost;
queue.add(child);
}
}
@@ -87,7 +94,7 @@ public class Astar{
float cost(int x1, int y1, int x2, int y2);
}
public interface TileHueristic{
public interface TileHeuristic{
float cost(Tile tile);
default float cost(Tile from, Tile tile){
@@ -14,31 +14,28 @@ import mindustry.game.Teams.*;
import mindustry.gen.*;
import mindustry.type.*;
import mindustry.world.*;
import mindustry.world.blocks.defense.*;
import mindustry.world.blocks.distribution.*;
import mindustry.world.blocks.payloads.*;
import mindustry.world.blocks.production.*;
import mindustry.world.blocks.storage.*;
import mindustry.world.blocks.storage.CoreBlock.*;
import static mindustry.Vars.*;
public class BaseAI{
public class BaseBuilderAI{
private static final Vec2 axis = new Vec2(), rotator = new Vec2();
private static final float correctPercent = 0.5f;
private static final int attempts = 4;
private static final int attempts = 6, coreUnitMultiplier = 2;
private static final float emptyChance = 0.01f;
private static final int timerStep = 0, timerSpawn = 1, timerRefreshPath = 2;
private static final float placeIntervalMin = 12f, placeIntervalMax = 2f;
private static final int pathStep = 50;
private static final Seq<Tile> tmpTiles = new Seq<>();
private static int correct = 0, incorrect = 0;
private static boolean anyDrills;
private int lastX, lastY, lastW, lastH;
private boolean triedWalls, foundPath;
private boolean foundPath;
TeamData data;
Interval timer = new Interval(4);
final TeamData data;
final Interval timer = new Interval(4);
IntSet path = new IntSet();
IntSet calcPath = new IntSet();
@@ -47,17 +44,26 @@ public class BaseAI{
int calcCount = 0;
int totalCalcs = 0;
public BaseAI(TeamData data){
public BaseBuilderAI(TeamData data){
this.data = data;
}
public void update(){
if(data.team.rules().aiCoreSpawn && timer.get(timerSpawn, 60 * 2.5f) && data.hasCore()){
//fill cores.
if(data.team.cores().size > 0){
var core = data.team.cores().first();
for(Item item : content.items()){
core.items.set(item, core.getMaximumAccepted(item));
}
}
if(data.team.rules().aiCoreSpawn && timer.get(timerSpawn, 60 * 6f) && data.hasCore()){
CoreBlock block = (CoreBlock)data.core().block;
int coreUnits = Groups.unit.count(u -> u.team == data.team && u.type == block.unitType);
int coreUnits = data.countType(block.unitType);
//create AI core unit(s)
if(!state.isEditor() && coreUnits < data.cores.size){
if(!state.isEditor() && coreUnits < data.cores.size * coreUnitMultiplier){
Unit unit = block.unitType.create(data.team);
unit.set(data.cores.random());
unit.add();
@@ -88,61 +94,59 @@ public class BaseAI{
calculating = false;
}
}else{
var field = pathfinder.getField(state.rules.waveTeam, Pathfinder.costGround, Pathfinder.fieldCore);
var field = pathfinder.getField(data.team, Pathfinder.costGround, Pathfinder.fieldCore);
int[][] weights = field.weights;
for(int i = 0; i < pathStep; i++){
int minCost = Integer.MAX_VALUE;
int cx = calcTile.x, cy = calcTile.y;
boolean foundAny = false;
for(Point2 p : Geometry.d4){
int nx = cx + p.x, ny = cy + p.y;
if(field.hasCompleteWeights()){
int[] weights = field.completeWeights;
for(int i = 0; i < pathStep; i++){
int minCost = Integer.MAX_VALUE;
int cx = calcTile.x, cy = calcTile.y;
boolean foundAny = false;
for(Point2 p : Geometry.d4){
int nx = cx + p.x, ny = cy + p.y, packed = world.packArray(nx, ny);
Tile other = world.tile(nx, ny);
if(other != null && weights[nx][ny] < minCost && weights[nx][ny] != -1){
minCost = weights[nx][ny];
calcTile = other;
foundAny = true;
Tile other = world.tile(nx, ny);
if(other != null && weights[packed] < minCost && weights[packed] != -1){
minCost = weights[packed];
calcTile = other;
foundAny = true;
}
}
//didn't find anything, break out of loop, this will trigger a clear later
if(!foundAny){
calcCount = Integer.MAX_VALUE;
break;
}
calcPath.add(calcTile.pos());
for(Point2 p : Geometry.d8){
calcPath.add(Point2.pack(p.x + calcTile.x, p.y + calcTile.y));
}
//found the end.
if(calcTile.build instanceof CoreBuild b && b.team != data.team){
//clean up calculations and flush results
calculating = false;
calcCount = 0;
path.clear();
path.addAll(calcPath);
calcPath.clear();
calcTile = null;
totalCalcs ++;
foundPath = true;
break;
}
calcCount ++;
}
//didn't find anything, break out of loop, this will trigger a clear later
if(!foundAny){
calcCount = Integer.MAX_VALUE;
break;
}
calcPath.add(calcTile.pos());
for(Point2 p : Geometry.d8){
calcPath.add(Point2.pack(p.x + calcTile.x, p.y + calcTile.y));
}
//found the end.
if(calcTile.build instanceof CoreBuild b && b.team == state.rules.defaultTeam){
//clean up calculations and flush results
calculating = false;
calcCount = 0;
path.clear();
path.addAll(calcPath);
calcPath.clear();
calcTile = null;
totalCalcs ++;
foundPath = true;
break;
}
calcCount ++;
}
}
}
//only schedule when there's something to build.
if(foundPath && data.blocks.isEmpty() && timer.get(timerStep, Mathf.lerp(20f, 4f, data.team.rules().aiTier))){
if(!triedWalls){
tryWalls();
triedWalls = true;
}
if((foundPath || !calculating) && data.plans.isEmpty() && timer.get(timerStep, Mathf.lerp(placeIntervalMin, placeIntervalMax, data.team.rules().buildAiTier))){
for(int i = 0; i < attempts; i++){
int range = 150;
@@ -209,6 +213,16 @@ public class BaseAI{
}
Tile wtile = world.tile(realX, realY);
if(tile.block instanceof PayloadConveyor || tile.block instanceof PayloadBlock){
//near a building
for(Point2 point : Edges.getEdges(tile.block.size)){
var t = world.build(tile.x + point.x, tile.y + point.y);
if(t != null){
return false;
}
}
}
//may intersect AI path
tmpTiles.clear();
if(tile.block.solid && wtile != null && wtile.getLinkedTilesAs(tile.block, tmpTiles).contains(t -> path.contains(t.pos()))){
@@ -218,7 +232,7 @@ public class BaseAI{
//make sure at least X% of resource requirements are met
correct = incorrect = 0;
anyDrills = false;
boolean anyDrills = false;
if(part.required instanceof Item){
for(Stile tile : result.tiles){
@@ -244,55 +258,9 @@ public class BaseAI{
//queue it
for(Stile tile : result.tiles){
data.blocks.add(new BlockPlan(cx + tile.x, cy + tile.y, tile.rotation, tile.block.id, tile.config));
data.plans.add(new BlockPlan(cx + tile.x, cy + tile.y, tile.rotation, tile.block, tile.config));
}
lastX = cx - 1;
lastY = cy - 1;
lastW = result.width + 2;
lastH = result.height + 2;
triedWalls = false;
return true;
}
private void tryWalls(){
Block wall = Blocks.copperWall;
Building spawnt = state.rules.defaultTeam.core() != null ? state.rules.defaultTeam.core() : data.team.core();
Tile spawn = spawnt == null ? null : spawnt.tile;
if(spawn == null) return;
for(int wx = lastX; wx <= lastX + lastW; wx++){
for(int wy = lastY; wy <= lastY + lastH; wy++){
Tile tile = world.tile(wx, wy);
if(tile == null || !tile.block().alwaysReplace) continue;
boolean any = false;
for(Point2 p : Geometry.d8){
if(Angles.angleDist(Angles.angle(p.x, p.y), spawn.angleTo(tile)) > 70){
continue;
}
Tile o = world.tile(tile.x + p.x, tile.y + p.y);
if(o != null && (o.block() instanceof PayloadAcceptor || o.block() instanceof PayloadConveyor)){
break;
}
if(o != null && o.team() == data.team && !(o.block() instanceof Wall)){
any = true;
break;
}
}
tmpTiles.clear();
if(any && Build.validPlace(wall, data.team, tile.x, tile.y, 0) && !tile.getLinkedTilesAs(wall, tmpTiles).contains(t -> path.contains(t.pos()))){
data.blocks.add(new BlockPlan(tile.x, tile.y, (short)0, wall.id, null));
}
}
}
}
}
}
+2 -2
View File
@@ -39,8 +39,8 @@ public class BaseRegistry{
//load ore types and corresponding items
for(Block block : content.blocks()){
if(block instanceof OreBlock && block.asFloor().itemDrop != null){
ores.put(block.asFloor().itemDrop, (OreBlock)block);
if(block instanceof OreBlock ore && ore.itemDrop != null && !ore.wallOre && !ores.containsKey(ore.itemDrop)){
ores.put(ore.itemDrop, ore);
}else if(block.isFloor() && block.asFloor().itemDrop != null && !oreFloors.containsKey(block.asFloor().itemDrop)){
oreFloors.put(block.asFloor().itemDrop, block.asFloor());
}
+335 -334
View File
@@ -4,262 +4,374 @@ import arc.*;
import arc.func.*;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.EnumSet;
import arc.struct.*;
import arc.util.*;
import mindustry.content.*;
import mindustry.core.*;
import mindustry.game.EventType.*;
import mindustry.game.*;
import mindustry.game.Teams.*;
import mindustry.gen.*;
import mindustry.logic.*;
import mindustry.type.*;
import mindustry.world.*;
import mindustry.world.blocks.*;
import mindustry.world.meta.*;
import java.util.*;
import static mindustry.Vars.*;
/** Class used for indexing special target blocks for AI. */
public class BlockIndexer{
/** Size of one quadrant. */
private static final int quadrantSize = 16;
private static final int quadrantSize = 20;
private static final Rect rect = new Rect();
private static boolean returnBool = false;
/** Set of all ores that are being scanned. */
private final ObjectSet<Item> scanOres = new ObjectSet<>();
private final IntSet intSet = new IntSet();
private final ObjectSet<Item> itemSet = new ObjectSet<>();
/** Stores all ore quadtrants on the map. */
private ObjectMap<Item, TileArray> ores = new ObjectMap<>();
/** Maps each team ID to a quarant. A quadrant is a grid of bits, where each bit is set if and only if there is a block of that team in that quadrant. */
private GridBits[] structQuadrants;
private int quadWidth, quadHeight;
/** Stores all ore quadrants on the map. Maps ID to qX to qY to a list of tiles with that ore. */
private IntSeq[][][] ores;
/** Stores all damaged tile entities by team. */
private ObjectSet<Building>[] damagedTiles = new ObjectSet[Team.all.length];
private Seq<Building>[] damagedTiles = new Seq[Team.all.length];
/** All ores available on this map. */
private ObjectSet<Item> allOres = new ObjectSet<>();
private ObjectIntMap<Item> allOres = new ObjectIntMap<>();
/** Stores teams that are present here as tiles. */
private Seq<Team> activeTeams = new Seq<>(Team.class);
/** Maps teams to a map of flagged tiles by flag. */
private TileArray[][] flagMap = new TileArray[Team.all.length][BlockFlag.all.length];
/** Max units by team. */
private int[] unitCaps = new int[Team.all.length];
/** Maps tile positions to their last known tile index data. */
private IntMap<TileIndex> typeMap = new IntMap<>();
/** Empty set used for returning. */
private TileArray emptySet = new TileArray();
private Seq<Building>[][] flagMap = new Seq[Team.all.length][BlockFlag.all.length];
/** Counts whether a certain floor is present in the world upon load. */
private boolean[] blocksPresent;
/** Array used for returning and reusing. */
private Seq<Tile> returnArray = new Seq<>();
/** Array used for returning and reusing. */
private Seq<Building> breturnArray = new Seq<>();
private Seq<Building> breturnArray = new Seq<>(Building.class);
public BlockIndexer(){
clearFlags();
Events.on(TilePreChangeEvent.class, event -> {
removeIndex(event.tile);
});
Events.on(TileChangeEvent.class, event -> {
updateIndices(event.tile);
addIndex(event.tile);
});
Events.on(WorldLoadEvent.class, event -> {
scanOres.clear();
scanOres.addAll(Item.getAllOres());
damagedTiles = new ObjectSet[Team.all.length];
flagMap = new TileArray[Team.all.length][BlockFlag.all.length];
unitCaps = new int[Team.all.length];
damagedTiles = new Seq[Team.all.length];
flagMap = new Seq[Team.all.length][BlockFlag.all.length];
activeTeams = new Seq<>(Team.class);
for(int i = 0; i < flagMap.length; i++){
for(int j = 0; j < BlockFlag.all.length; j++){
flagMap[i][j] = new TileArray();
clearFlags();
allOres.clear();
ores = new IntSeq[content.items().size][][];
quadWidth = Mathf.ceil(world.width() / (float)quadrantSize);
quadHeight = Mathf.ceil(world.height() / (float)quadrantSize);
blocksPresent = new boolean[content.blocks().size];
//so WorldLoadEvent gets called twice sometimes... ugh
for(Team team : Team.all){
var data = state.teams.get(team);
if(data != null){
if(data.buildingTree != null) data.buildingTree.clear();
if(data.turretTree != null) data.turretTree.clear();
}
}
typeMap.clear();
allOres.clear();
ores = null;
//create bitset for each team type that contains each quadrant
structQuadrants = new GridBits[Team.all.length];
for(Tile tile : world.tiles){
process(tile);
if(tile.build != null && tile.build.damaged()){
notifyTileDamaged(tile.build);
}
var drop = tile.drop();
if(tile.drop() != null) allOres.add(tile.drop());
}
if(drop != null){
int qx = (tile.x / quadrantSize);
int qy = (tile.y / quadrantSize);
for(int x = 0; x < quadWidth(); x++){
for(int y = 0; y < quadHeight(); y++){
updateQuadrant(world.tile(x * quadrantSize, y * quadrantSize));
//add position of quadrant to list
if(tile.block() == Blocks.air){
if(ores[drop.id] == null){
ores[drop.id] = new IntSeq[quadWidth][quadHeight];
}
if(ores[drop.id][qx][qy] == null){
ores[drop.id][qx][qy] = new IntSeq(false, 16);
}
ores[drop.id][qx][qy].add(tile.pos());
allOres.increment(drop);
}
}
}
scanOres();
});
}
public void updateIndices(Tile tile){
if(typeMap.get(tile.pos()) != null){
TileIndex index = typeMap.get(tile.pos());
for(BlockFlag flag : index.flags){
getFlagged(index.team)[flag.ordinal()].remove(tile);
public void removeIndex(Tile tile){
var team = tile.team();
if(tile.build != null && tile.isCenter()){
var build = tile.build;
var flags = tile.block().flags;
var data = team.data();
if(flags.size > 0){
for(BlockFlag flag : flags.array){
getFlagged(team)[flag.ordinal()].remove(build);
}
}
if(index.flags.contains(BlockFlag.unitModifier)){
updateCap(index.team);
//no longer part of the building list
data.buildings.remove(build);
data.buildingTypes.get(build.block, () -> new Seq<>(false)).remove(build);
//update the unit cap when building is removed
data.unitCap -= tile.block().unitCapModifier;
//unregister building from building quadtree
if(data.buildingTree != null){
data.buildingTree.remove(build);
}
//remove indexed turret
if(data.turretTree != null && build.block.attacks){
data.turretTree.remove(build);
}
//unregister damaged buildings
if(build.wasDamaged && damagedTiles[team.id] != null){
damagedTiles[team.id].remove(build);
}
//is no longer registered
build.wasDamaged = false;
}
}
public void addIndex(Tile tile){
process(tile);
updateQuadrant(tile);
}
private TileArray[] getFlagged(Team team){
return flagMap[team.id];
}
var drop = tile.drop();
if(drop != null && ores != null){
int qx = tile.x / quadrantSize;
int qy = tile.y / quadrantSize;
private GridBits structQuadrant(Team t){
if(structQuadrants[t.id] == null){
structQuadrants[t.id] = new GridBits(Mathf.ceil(world.width() / (float)quadrantSize), Mathf.ceil(world.height() / (float)quadrantSize));
}
return structQuadrants[t.id];
}
if(ores[drop.id] == null){
ores[drop.id] = new IntSeq[quadWidth][quadHeight];
}
if(ores[drop.id][qx][qy] == null){
ores[drop.id][qx][qy] = new IntSeq(false, 16);
}
/** Updates all the structure quadrants for a newly activated team. */
public void updateTeamIndex(Team team){
if(structQuadrants == null) return;
int pos = tile.pos();
var seq = ores[drop.id][qx][qy];
//go through every tile... ouch
for(Tile tile : world.tiles){
if(tile.team() == team){
int quadrantX = tile.x / quadrantSize;
int quadrantY = tile.y / quadrantSize;
structQuadrant(team).set(quadrantX, quadrantY);
if(tile.block() == Blocks.air){
//add the index if it is a valid new spot to mine at
if(!seq.contains(pos)){
seq.add(pos);
allOres.increment(drop);
}
}else if(seq.contains(pos)){ //otherwise, it likely became blocked, remove it
seq.removeValue(pos);
allOres.increment(drop, -1);
}
}
}
/** @return whether a certain block is anywhere on this map. */
public boolean isBlockPresent(Block block){
return blocksPresent != null && blocksPresent[block.id];
}
private void clearFlags(){
for(int i = 0; i < flagMap.length; i++){
for(int j = 0; j < BlockFlag.all.length; j++){
flagMap[i][j] = new Seq();
}
}
}
private Seq<Building>[] getFlagged(Team team){
return flagMap[team.id];
}
/** @return whether this item is present on this map. */
public boolean hasOre(Item item){
return allOres.contains(item);
return allOres.get(item) > 0;
}
/** Returns all damaged tiles by team. */
public ObjectSet<Building> getDamaged(Team team){
breturnArray.clear();
public Seq<Building> getDamaged(Team team){
if(damagedTiles[team.id] == null){
damagedTiles[team.id] = new ObjectSet<>();
return damagedTiles[team.id] = new Seq<>(false);
}
ObjectSet<Building> set = damagedTiles[team.id];
for(Building build : set){
if((!build.isValid() || build.team != team || !build.damaged()) || build.block instanceof ConstructBlock){
breturnArray.add(build);
}
}
var tiles = damagedTiles[team.id];
tiles.removeAll(b -> !b.damaged());
for(Building tile : breturnArray){
set.remove(tile);
}
return set;
return tiles;
}
/** Get all allied blocks with a flag. */
public TileArray getAllied(Team team, BlockFlag type){
public Seq<Building> getFlagged(Team team, BlockFlag type){
return flagMap[team.id][type.ordinal()];
}
@Nullable
public Tile findClosestFlag(float x, float y, Team team, BlockFlag flag){
return Geometry.findClosest(x, y, getAllied(team, flag));
public Building findClosestFlag(float x, float y, Team team, BlockFlag flag){
return Geometry.findClosest(x, y, getFlagged(team, flag));
}
public boolean eachBlock(Teamc team, float range, Boolf<Building> pred, Cons<Building> cons){
return eachBlock(team.team(), team.getX(), team.getY(), range, pred, cons);
}
public boolean eachBlock(Team team, float wx, float wy, float range, Boolf<Building> pred, Cons<Building> cons){
intSet.clear();
public boolean eachBlock(@Nullable Team team, float wx, float wy, float range, Boolf<Building> pred, Cons<Building> cons){
int tx = World.toTile(wx);
int ty = World.toTile(wy);
if(team == null){
returnBool = false;
int tileRange = (int)(range / tilesize + 1);
boolean any = false;
for(int x = -tileRange + tx; x <= tileRange + tx; x++){
for(int y = -tileRange + ty; y <= tileRange + ty; y++){
if(!Mathf.within(x * tilesize, y * tilesize, wx, wy, range)) continue;
Building other = world.build(x, y);
if(other == null) continue;
if((team == null || other.team == team) && pred.get(other) && intSet.add(other.pos())){
cons.get(other);
any = true;
allBuildings(wx, wy, range, b -> {
if(pred.get(b)){
returnBool = true;
cons.get(b);
}
}
});
return returnBool;
}else{
breturnArray.clear();
var buildings = team.data().buildingTree;
if(buildings == null) return false;
buildings.intersect(wx - range, wy - range, range*2f, range*2f, b -> {
if(b.within(wx, wy, range + b.hitSize() / 2f) && pred.get(b)){
breturnArray.add(b);
}
});
}
return any;
int size = breturnArray.size;
var items = breturnArray.items;
for(int i = 0; i < size; i++){
cons.get(items[i]);
items[i] = null;
}
breturnArray.size = 0;
return size > 0;
}
/** Does not work with null teams. */
public boolean eachBlock(Team team, Rect rect, Boolf<Building> pred, Cons<Building> cons){
if(team == null) return false;
breturnArray.clear();
var buildings = team.data().buildingTree;
if(buildings == null) return false;
buildings.intersect(rect, b -> {
if(pred.get(b)){
breturnArray.add(b);
}
});
int size = breturnArray.size;
var items = breturnArray.items;
for(int i = 0; i < size; i++){
cons.get(items[i]);
items[i] = null;
}
breturnArray.size = 0;
return size > 0;
}
/** Get all enemy blocks with a flag. */
public Seq<Tile> getEnemy(Team team, BlockFlag type){
returnArray.clear();
public Seq<Building> getEnemy(Team team, BlockFlag type){
breturnArray.clear();
Seq<TeamData> data = state.teams.present;
//when team data is not initialized, scan through every team. this is terrible
if(data.isEmpty()){
for(Team enemy : Team.all){
if(enemy == team) continue;
TileArray set = getFlagged(enemy)[type.ordinal()];
if(enemy == team || (enemy == Team.derelict && !state.rules.coreCapture)) continue;
var set = getFlagged(enemy)[type.ordinal()];
if(set != null){
for(Tile tile : set){
returnArray.add(tile);
}
breturnArray.addAll(set);
}
}
}else{
for(int i = 0; i < data.size; i++){
Team enemy = data.items[i].team;
if(enemy == team) continue;
TileArray set = getFlagged(enemy)[type.ordinal()];
if(enemy == team || (enemy == Team.derelict && !state.rules.coreCapture)) continue;
var set = getFlagged(enemy)[type.ordinal()];
if(set != null){
for(Tile tile : set){
returnArray.add(tile);
}
breturnArray.addAll(set);
}
}
}
return returnArray;
return breturnArray;
}
public void notifyTileDamaged(Building entity){
if(damagedTiles[entity.team.id] == null){
damagedTiles[entity.team.id] = new ObjectSet<>();
public void notifyHealthChanged(Building build){
boolean damaged = build.damaged();
if(build.wasDamaged != damaged){
if(damagedTiles[build.team.id] == null){
damagedTiles[build.team.id] = new Seq<>(false);
}
if(damaged){
//is now damaged, add to array
damagedTiles[build.team.id].add(build);
}else{
//no longer damaged, remove
damagedTiles[build.team.id].remove(build);
}
build.wasDamaged = damaged;
}
}
public void allBuildings(float x, float y, float range, Cons<Building> cons){
breturnArray.clear();
for(int i = 0; i < activeTeams.size; i++){
Team team = activeTeams.items[i];
var buildings = team.data().buildingTree;
if(buildings == null) continue;
buildings.intersect(x - range, y - range, range*2f, range*2f, breturnArray);
}
damagedTiles[entity.team.id].add(entity);
var items = breturnArray.items;
int size = breturnArray.size;
for(int i = 0; i < size; i++){
var b = items[i];
if(b != null && b.within(x, y, range + b.hitSize()/2f)){
cons.get(b);
}
items[i] = null;
}
breturnArray.size = 0;
}
public Building findEnemyTile(Team team, float x, float y, float range, Boolf<Building> pred){
Building target = null;
float targetDist = 0;
for(int i = 0; i < activeTeams.size; i++){
Team enemy = activeTeams.items[i];
if(enemy == team || (enemy == Team.derelict && !state.rules.coreCapture)) continue;
if(enemy == team || team == Team.derelict) continue;
Building candidate = indexer.findTile(enemy, x, y, range, b -> pred.get(b) && b.isDiscovered(team), true);
if(candidate == null) continue;
Building entity = indexer.findTile(enemy, x, y, range, pred, true);
if(entity != null){
return entity;
//if a block has the same priority, the closer one should be targeted
float dist = candidate.dst(x, y) - candidate.hitSize() / 2f;
if(target == null ||
//if its closer and is at least equal priority
(dist < targetDist && candidate.block.priority >= target.block.priority) ||
// block has higher priority (so range doesnt matter)
(candidate.block.priority > target.block.priority)){
target = candidate;
targetDist = dist;
}
}
return null;
return target;
}
public Building findTile(Team team, float x, float y, float range, Boolf<Building> pred){
@@ -269,59 +381,52 @@ public class BlockIndexer{
public Building findTile(Team team, float x, float y, float range, Boolf<Building> pred, boolean usePriority){
Building closest = null;
float dst = 0;
float range2 = range * range;
var buildings = team.data().buildingTree;
if(buildings == null) return null;
for(int rx = Math.max((int)((x - range) / tilesize / quadrantSize), 0); rx <= (int)((x + range) / tilesize / quadrantSize) && rx < quadWidth(); rx++){
for(int ry = Math.max((int)((y - range) / tilesize / quadrantSize), 0); ry <= (int)((y + range) / tilesize / quadrantSize) && ry < quadHeight(); ry++){
breturnArray.clear();
buildings.intersect(rect.setCentered(x, y, range * 2f), breturnArray);
if(!getQuad(team, rx, ry)) continue;
for(int i = 0; i < breturnArray.size; i++){
var next = breturnArray.items[i];
for(int tx = rx * quadrantSize; tx < (rx + 1) * quadrantSize && tx < world.width(); tx++){
for(int ty = ry * quadrantSize; ty < (ry + 1) * quadrantSize && ty < world.height(); ty++){
Building e = world.build(tx, ty);
if(!pred.get(next) || !next.block.targetable) continue;
if(e == null || e.team != team || !pred.get(e) || !e.block.targetable || e.team == Team.derelict) continue;
float ndst = e.dst2(x, y);
if(ndst < range2 && (closest == null ||
//this one is closer, and it is at least of equal priority
(ndst < dst && (!usePriority || closest.block.priority.ordinal() <= e.block.priority.ordinal())) ||
//priority is used, and new block has higher priority regardless of range
(usePriority && closest.block.priority.ordinal() < e.block.priority.ordinal()))){
dst = ndst;
closest = e;
}
}
}
float bdst = next.dst(x, y) - next.hitSize() / 2f;
if(bdst < range && (closest == null ||
//this one is closer, and it is at least of equal priority
(bdst < dst && (!usePriority || closest.block.priority <= next.block.priority)) ||
//priority is used, and new block has higher priority regardless of range
(usePriority && closest.block.priority < next.block.priority))){
dst = bdst;
closest = next;
}
}
return closest;
}
/**
* Returns a set of tiles that have ores of the specified type nearby.
* While each tile in the set is not guaranteed to have an ore directly on it,
* each tile will at least have an ore within {@link #quadrantSize} / 2 blocks of it.
* Only specific ore types are scanned. See {@link #scanOres}.
*/
public TileArray getOrePositions(Item item){
return ores.get(item, emptySet);
}
/** Find the closest ore block relative to a position. */
public Tile findClosestOre(float xp, float yp, Item item){
Tile tile = Geometry.findClosest(xp, yp, getOrePositions(item));
if(tile == null) return null;
for(int x = Math.max(0, tile.x - quadrantSize / 2); x < tile.x + quadrantSize / 2 && x < world.width(); x++){
for(int y = Math.max(0, tile.y - quadrantSize / 2); y < tile.y + quadrantSize / 2 && y < world.height(); y++){
Tile res = world.tile(x, y);
if(res.block() == Blocks.air && res.drop() == item){
return res;
if(ores[item.id] != null){
float minDst = 0f;
Tile closest = null;
for(int qx = 0; qx < quadWidth; qx++){
for(int qy = 0; qy < quadHeight; qy++){
var arr = ores[item.id][qx][qy];
if(arr != null && arr.size > 0){
Tile tile = world.tile(arr.first());
if(tile.block() == Blocks.air){
float dst = Mathf.dst2(xp, yp, tile.worldx(), tile.worldy());
if(closest == null || dst < minDst){
closest = tile;
minDst = dst;
}
}
}
}
}
return closest;
}
return null;
@@ -332,177 +437,73 @@ public class BlockIndexer{
return findClosestOre(unit.x, unit.y, item);
}
/** @return extra unit cap of a team. This is added onto the base value. */
public int getExtraUnits(Team team){
return unitCaps[team.id];
}
private void updateCap(Team team){
TileArray capped = getFlagged(team)[BlockFlag.unitModifier.ordinal()];
unitCaps[team.id] = 0;
for(Tile capper : capped){
unitCaps[team.id] += capper.block().unitCapModifier;
}
}
private void process(Tile tile){
if(tile.block().flags.size() > 0 && tile.team() != Team.derelict && tile.isCenter()){
TileArray[] map = getFlagged(tile.team());
var team = tile.team();
//only process entity changes with centered tiles
if(tile.isCenter() && tile.build != null){
var data = team.data();
for(BlockFlag flag : tile.block().flags){
if(tile.block().flags.size > 0 && tile.isCenter()){
var map = getFlagged(team);
TileArray arr = map[flag.ordinal()];
arr.add(tile);
map[flag.ordinal()] = arr;
}
if(tile.block().flags.contains(BlockFlag.unitModifier)){
updateCap(tile.team());
}
typeMap.put(tile.pos(), new TileIndex(tile.block().flags, tile.team()));
}
if(!activeTeams.contains(tile.team())){
activeTeams.add(tile.team());
}
if(ores == null) return;
int quadrantX = tile.x / quadrantSize;
int quadrantY = tile.y / quadrantSize;
itemSet.clear();
Tile rounded = world.rawTile(Mathf.clamp(quadrantX * quadrantSize + quadrantSize / 2, 0, world.width() - 1), Mathf.clamp(quadrantY * quadrantSize + quadrantSize / 2, 0, world.height() - 1));
//find all items that this quadrant contains
for(int x = Math.max(0, rounded.x - quadrantSize / 2); x < rounded.x + quadrantSize / 2 && x < world.width(); x++){
for(int y = Math.max(0, rounded.y - quadrantSize / 2); y < rounded.y + quadrantSize / 2 && y < world.height(); y++){
Tile result = world.tile(x, y);
if(result == null || result.drop() == null || !scanOres.contains(result.drop()) || result.block() != Blocks.air) continue;
itemSet.add(result.drop());
}
}
//update quadrant at this position
for(Item item : scanOres){
TileArray set = ores.get(item);
//update quadrant status depending on whether the item is in it
if(!itemSet.contains(item)){
set.remove(rounded);
}else{
set.add(rounded);
}
}
}
private void updateQuadrant(Tile tile){
if(structQuadrants == null) return;
//this quadrant is now 'dirty', re-scan the whole thing
int quadrantX = tile.x / quadrantSize;
int quadrantY = tile.y / quadrantSize;
for(Team team : activeTeams){
GridBits bits = structQuadrant(team);
//fast-set this quadrant to 'occupied' if the tile just placed is already of this team
if(tile.team() == team && tile.build != null && tile.block().targetable){
bits.set(quadrantX, quadrantY);
continue; //no need to process futher
}
bits.set(quadrantX, quadrantY, false);
outer:
for(int x = quadrantX * quadrantSize; x < world.width() && x < (quadrantX + 1) * quadrantSize; x++){
for(int y = quadrantY * quadrantSize; y < world.height() && y < (quadrantY + 1) * quadrantSize; y++){
Building result = world.build(x, y);
//when a targetable block is found, mark this quadrant as occupied and stop searching
if(result != null && result.team == team){
bits.set(quadrantX, quadrantY);
break outer;
}
for(BlockFlag flag : tile.block().flags.array){
map[flag.ordinal()].add(tile.build);
}
}
}
}
private boolean getQuad(Team team, int quadrantX, int quadrantY){
return structQuadrant(team).get(quadrantX, quadrantY);
}
//record in list of buildings
data.buildings.add(tile.build);
data.buildingTypes.get(tile.block(), () -> new Seq<>(false)).add(tile.build);
private int quadWidth(){
return Mathf.ceil(world.width() / (float)quadrantSize);
}
//update the unit cap when new tile is registered
data.unitCap += tile.block().unitCapModifier;
private int quadHeight(){
return Mathf.ceil(world.height() / (float)quadrantSize);
}
private void scanOres(){
ores = new ObjectMap<>();
//initialize ore map with empty sets
for(Item item : scanOres){
ores.put(item, new TileArray());
}
for(Tile tile : world.tiles){
int qx = (tile.x / quadrantSize);
int qy = (tile.y / quadrantSize);
//add position of quadrant to list when an ore is found
if(tile.drop() != null && scanOres.contains(tile.drop()) && tile.block() == Blocks.air){
ores.get(tile.drop()).add(world.tile(
//make sure to clamp quadrant middle position, since it might go off bounds
Mathf.clamp(qx * quadrantSize + quadrantSize / 2, 0, world.width() - 1),
Mathf.clamp(qy * quadrantSize + quadrantSize / 2, 0, world.height() - 1)));
if(!activeTeams.contains(team)){
activeTeams.add(team);
}
//insert the new tile into the quadtree for targeting
if(data.buildingTree == null){
data.buildingTree = new QuadTree<>(new Rect(0, 0, world.unitWidth(), world.unitHeight()));
}
data.buildingTree.insert(tile.build);
if(tile.block().attacks && tile.build instanceof Ranged){
if(data.turretTree == null){
data.turretTree = new TurretQuadtree(new Rect(0, 0, world.unitWidth(), world.unitHeight()));
}
data.turretTree.insert(tile.build);
}
notifyHealthChanged(tile.build);
}
if(blocksPresent != null){
if(!tile.block().isStatic()){
blocksPresent[tile.floorID()] = true;
blocksPresent[tile.overlayID()] = true;
}
//bounds checks only needed in very specific scenarios
if(tile.blockID() < blocksPresent.length) blocksPresent[tile.blockID()] = true;
}
}
private static class TileIndex{
public final EnumSet<BlockFlag> flags;
public final Team team;
static class TurretQuadtree extends QuadTree<Building>{
public TileIndex(EnumSet<BlockFlag> flags, Team team){
this.flags = flags;
this.team = team;
}
}
public static class TileArray implements Iterable<Tile>{
Seq<Tile> tiles = new Seq<>(false, 16);
IntSet contained = new IntSet();
public void add(Tile tile){
if(contained.add(tile.pos())){
tiles.add(tile);
}
}
public void remove(Tile tile){
if(contained.remove(tile.pos())){
tiles.remove(tile);
}
}
public int size(){
return tiles.size;
}
public Tile first(){
return tiles.first();
public TurretQuadtree(Rect bounds){
super(bounds);
}
@Override
public Iterator<Tile> iterator(){
return tiles.iterator();
public void hitbox(Building build){
tmp.setCentered(build.x, build.y, ((Ranged)build).range() * 2f);
}
@Override
protected QuadTree<Building> newChild(Rect rect){
return new TurretQuadtree(rect);
}
}
}
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
package mindustry.ai;
import arc.util.*;
/** A priority queue. */
@SuppressWarnings("unchecked")
public class PathfindQueue{
private static final double CAPACITY_RATIO_LOW = 1.5f;
private static final double CAPACITY_RATIO_HI = 2f;
/**
* Priority queue represented as a balanced binary heap: the two children of queue[n] are queue[2*n+1] and queue[2*(n+1)]. The
* priority queue is ordered by the elements' natural ordering: For each node n in the heap and each descendant d of n, n <= d.
* The element with the lowest value is in queue[0], assuming the queue is nonempty.
*/
public int[] queue;
/** Weights of each object in the queue. */
public float[] weights;
/** The number of elements in the priority queue. */
public int size = 0;
public PathfindQueue(){
this(12);
}
public PathfindQueue(int initialCapacity){
this.queue = new int[initialCapacity];
this.weights = new float[initialCapacity];
}
public boolean empty(){
return size == 0;
}
/**
* Inserts the specified element into this priority queue. If {@code uniqueness} is enabled and this priority queue already
* contains the element, the call leaves the queue unchanged and returns false.
* @return true if the element was added to this queue, else false
* @throws ClassCastException if the specified element cannot be compared with elements currently in this priority queue
* according to the priority queue's ordering
* @throws IllegalArgumentException if the specified element is null
*/
public boolean add(int e, float weight){
int i = size;
if(i >= queue.length) growToSize(i + 1);
size = i + 1;
if(i == 0){
queue[0] = e;
weights[0] = weight;
}else{
siftUp(i, e, weight);
}
return true;
}
/**
* Retrieves, but does not remove, the head of this queue. If this queue is empty, {@code 0} is returned.
* @return the head of this queue
*/
public int peek(){
return size == 0 ? 0 : queue[0];
}
/** Removes all of the elements from this priority queue. The queue will be empty after this call returns. */
public void clear(){
size = 0;
}
/**
* Retrieves and removes the head of this queue, or returns {@code null} if this queue is empty.
* @return the head of this queue, or {@code null} if this queue is empty.
*/
public int poll(){
if(size == 0) return 0;
int s = --size;
int result = queue[0];
int x = queue[s];
if(s != 0) siftDown(0, x, weights[s]);
return result;
}
/**
* Inserts item x at position k, maintaining heap invariant by promoting x up the tree until it is greater than or equal to its
* parent, or is the root.
* @param k the position to fill
* @param x the item to insert
*/
private void siftUp(int k, int x, float weight){
while(k > 0){
int parent = (k - 1) >>> 1;
int e = queue[parent];
if(weight >= weights[parent]) break;
queue[k] = e;
weights[k] = weights[parent];
k = parent;
}
queue[k] = x;
weights[k] = weight;
}
/**
* Inserts item x at position k, maintaining heap invariant by demoting x down the tree repeatedly until it is less than or
* equal to its children or is a leaf.
* @param k the position to fill
* @param x the item to insert
*/
private void siftDown(int k, int x, float weight){
int half = size >>> 1; // loop while a non-leaf
while(k < half){
int child = (k << 1) + 1; // assume left child is least
int c = queue[child];
int right = child + 1;
if(right < size && weights[child] > weights[right]){
c = queue[child = right];
}
if(weight <= weights[child]) break;
queue[k] = c;
weights[k] = weights[child];
k = child;
}
queue[k] = x;
weights[k] = weight;
}
/**
* Increases the capacity of the array.
* @param minCapacity the desired minimum capacity
*/
private void growToSize(int minCapacity){
if(minCapacity < 0) // overflow
throw new ArcRuntimeException("Capacity upper limit exceeded.");
int oldCapacity = queue.length;
// Double size if small; else grow by 50%
int newCapacity = (int)((oldCapacity < 64) ? ((oldCapacity + 1) * CAPACITY_RATIO_HI) : (oldCapacity * CAPACITY_RATIO_LOW));
if(newCapacity < 0) // overflow
newCapacity = Integer.MAX_VALUE;
if(newCapacity < minCapacity) newCapacity = minCapacity;
int[] newQueue = new int[newCapacity];
float[] newWeights = new float[newCapacity];
System.arraycopy(queue, 0, newQueue, 0, size);
System.arraycopy(weights, 0, newWeights, 0, size);
queue = newQueue;
weights = newWeights;
}
}
+241 -147
View File
@@ -2,46 +2,50 @@ package mindustry.ai;
import arc.*;
import arc.func.*;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import arc.util.async.*;
import mindustry.annotations.Annotations.*;
import mindustry.content.*;
import mindustry.core.*;
import mindustry.game.EventType.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.world.*;
import mindustry.world.blocks.environment.*;
import mindustry.world.blocks.storage.*;
import mindustry.world.meta.*;
import static mindustry.Vars.*;
import static mindustry.world.meta.BlockFlag.*;
public class Pathfinder implements Runnable{
private static final long maxUpdate = Time.millisToNanos(7);
private static final long maxUpdate = Time.millisToNanos(8);
private static final int updateFPS = 60;
private static final int updateInterval = 1000 / updateFPS;
private static final int impassable = -1;
private static final int fieldTimeout = 1000 * 60 * 2;
/** cached world size */
static int wwidth, wheight;
static final int impassable = -1;
public static final int
fieldCore = 0,
fieldRally = 1;
fieldCore = 0;
public static final Seq<Prov<Flowfield>> fieldTypes = Seq.with(
EnemyCoreField::new,
RallyField::new
EnemyCoreField::new
);
public static final int
costGround = 0,
costLegs = 1,
costNaval = 2;
costNaval = 2,
costHover = 3;
public static final Seq<PathCost> costTypes = Seq.with(
//ground
(team, tile) -> (PathTile.team(tile) == team.id || PathTile.team(tile) == 0) && PathTile.solid(tile) ? impassable : 1 +
(team, tile) ->
(PathTile.allDeep(tile) || ((PathTile.team(tile) == team && !PathTile.teamPassable(tile)) || PathTile.team(tile) == 0) && PathTile.solid(tile)) ? impassable : 1 +
PathTile.health(tile) * 5 +
(PathTile.nearSolid(tile) ? 2 : 0) +
(PathTile.nearLiquid(tile) ? 6 : 0) +
@@ -49,21 +53,31 @@ public class Pathfinder implements Runnable{
(PathTile.damages(tile) ? 30 : 0),
//legs
(team, tile) -> PathTile.legSolid(tile) ? impassable : 1 +
(team, tile) ->
PathTile.legSolid(tile) ? impassable : 1 +
(PathTile.deep(tile) ? 6000 : 0) + //leg units can now drown
(PathTile.solid(tile) ? 5 : 0),
//water
(team, tile) -> PathTile.solid(tile) || !PathTile.liquid(tile) ? 200 : 2 +
(team, tile) ->
(!PathTile.liquid(tile) ? 6000 : 1) +
PathTile.health(tile) * 5 +
(PathTile.nearGround(tile) || PathTile.nearSolid(tile) ? 14 : 0) +
(PathTile.deep(tile) ? -1 : 0) +
(PathTile.damages(tile) ? 35 : 0)
(PathTile.deep(tile) ? 0 : 1) +
(PathTile.damages(tile) ? 35 : 0),
//hover
(team, tile) ->
(((PathTile.team(tile) == team && !PathTile.teamPassable(tile)) || PathTile.team(tile) == 0) && PathTile.solid(tile)) ? impassable : 1 +
PathTile.health(tile) * 5 +
(PathTile.nearSolid(tile) ? 2 : 0)
);
//maps team, cost, type to flow field
Flowfield[][][] cache;
/** tile data, see PathTileStruct - kept as a separate array for threading reasons */
int[] tiles = new int[0];
/** tile data, see PathTileStruct */
int[][] tiles = new int[0][0];
/** maps team, cost, type to flow field*/
Flowfield[][][] cache;
/** unordered array of path data for iteration only. DO NOT iterate or access this in the main thread. */
Seq<Flowfield> threadList = new Seq<>(), mainList = new Seq<>();
/** handles task scheduling on the update thread. */
@@ -79,20 +93,29 @@ public class Pathfinder implements Runnable{
stop();
//reset and update internal tile array
tiles = new int[world.width()][world.height()];
tiles = new int[world.width() * world.height()];
wwidth = world.width();
wheight = world.height();
threadList = new Seq<>();
mainList = new Seq<>();
clearCache();
for(Tile tile : world.tiles){
tiles[tile.x][tile.y] = packTile(tile);
for(int i = 0; i < tiles.length; i++){
Tile tile = world.tiles.geti(i);
tiles[i] = packTile(tile);
}
preloadPath(getField(state.rules.waveTeam, costGround, fieldCore));
//don't bother setting up paths unless necessary
if(state.rules.waveTeam.needsFlowField() && !net.client()){
preloadPath(getField(state.rules.waveTeam, costGround, fieldCore));
Log.debug("Preloading ground enemy flowfield.");
//preload water on naval maps
if(spawner.getSpawns().contains(t -> t.floor().isLiquid)){
preloadPath(getField(state.rules.waveTeam, costNaval, fieldCore));
Log.debug("Preloading naval enemy flowfield.");
}
//preload water on naval maps
if(spawner.getSpawns().contains(t -> t.floor().isLiquid)){
preloadPath(getField(state.rules.waveTeam, costNaval, fieldCore));
}
start();
@@ -101,6 +124,35 @@ public class Pathfinder implements Runnable{
Events.on(ResetEvent.class, event -> stop());
Events.on(TileChangeEvent.class, event -> updateTile(event.tile));
//remove nearSolid flag for tiles
Events.on(TilePreChangeEvent.class, event -> {
Tile tile = event.tile;
if(tile.solid()){
for(int i = 0; i < 4; i++){
Tile other = tile.nearby(i);
if(other != null){
//other tile needs to update its nearSolid to be false if it's not solid and this tile just got un-solidified
if(!other.solid()){
boolean otherNearSolid = false;
for(int j = 0; j < 4; j++){
Tile othernear = other.nearby(i);
if(othernear != null && othernear.solid()){
otherNearSolid = true;
break;
}
}
int arr = other.array();
//the other tile is no longer near solid, remove the solid bit
if(!otherNearSolid && tiles.length > arr){
tiles[arr] &= ~(PathTile.bitMaskNearSolid);
}
}
}
}
}
});
}
private void clearCache(){
@@ -108,36 +160,60 @@ public class Pathfinder implements Runnable{
}
/** Packs a tile into its internal representation. */
private int packTile(Tile tile){
boolean nearLiquid = false, nearSolid = false, nearGround = false;
public int packTile(Tile tile){
boolean nearLiquid = false, nearSolid = false, nearLegSolid = false, nearGround = false, solid = tile.solid(), allDeep = tile.floor().isDeep();
for(int i = 0; i < 4; i++){
Tile other = tile.nearby(i);
if(other != null){
if(other.floor().isLiquid) nearLiquid = true;
if(other.solid()) nearSolid = true;
if(!other.floor().isLiquid) nearGround = true;
Floor floor = other.floor();
boolean osolid = other.solid();
if(floor.isLiquid && floor.isDeep()) nearLiquid = true;
//TODO potentially strange behavior when teamPassable is false for other teams?
if(osolid && !other.block().teamPassable) nearSolid = true;
if(!floor.isLiquid) nearGround = true;
if(!floor.isDeep()) allDeep = false;
if(other.legSolid()) nearLegSolid = true;
//other tile is now near solid
if(solid && !tile.block().teamPassable){
tiles[other.array()] |= PathTile.bitMaskNearSolid;
}
}
}
int tid = tile.getTeamID();
return PathTile.get(
tile.build == null || !tile.solid() || tile.block() instanceof CoreBlock ? 0 : Math.min((int)(tile.build.health / 40), 80),
tile.getTeamID(),
tile.solid(),
tile.build == null || !solid || tile.block() instanceof CoreBlock ? 0 : Math.min((int)(tile.build.health / 40), 80),
tid == 0 && tile.build != null && state.rules.coreCapture ? 255 : tid, //use teamid = 255 when core capture is enabled to mark out derelict structures
solid,
tile.floor().isLiquid,
tile.staticDarkness() >= 2 || (tile.floor().solid && tile.block() == Blocks.air),
tile.legSolid(),
nearLiquid,
nearGround,
nearSolid,
nearLegSolid,
tile.floor().isDeep(),
tile.floor().damageTaken > 0.00001f
tile.floor().damageTaken > 0.00001f,
allDeep,
tile.block().teamPassable
);
}
public int get(int x, int y){
return tiles[x + y * wwidth];
}
/** Starts or restarts the pathfinding thread. */
private void start(){
stop();
thread = Threads.daemon(this);
if(net.client()) return;
thread = new Thread(this, "Pathfinder");
thread.setPriority(Thread.MIN_PRIORITY);
thread.setDaemon(true);
thread.start();
}
/** Stops the pathfinding thread. */
@@ -150,15 +226,14 @@ public class Pathfinder implements Runnable{
}
/** Update a tile in the internal pathfinding grid.
* Causes a complete pathfinding reclaculation. Main thread only. */
* Causes a complete pathfinding recalculation. Main thread only. */
public void updateTile(Tile tile){
if(net.client()) return;
int x = tile.x, y = tile.y;
tile.getLinkedTiles(t -> {
if(Structs.inBounds(t.x, t.y, tiles)){
tiles[t.x][t.y] = packTile(t);
int pos = t.array();
if(pos < tiles.length){
tiles[pos] = packTile(t);
}
});
@@ -166,17 +241,19 @@ public class Pathfinder implements Runnable{
for(Flowfield path : mainList){
if(path != null){
synchronized(path.targets){
path.targets.clear();
path.getPositions(path.targets);
path.updateTargetPositions();
}
}
}
//mark every flow field as dirty, so it updates when it's done
queue.post(() -> {
for(Flowfield data : threadList){
updateTargets(data, x, y);
data.dirty = true;
}
});
controlPath.updateTile(tile);
}
/** Thread implementation. */
@@ -189,34 +266,16 @@ public class Pathfinder implements Runnable{
if(state.isPlaying()){
queue.run();
//total update time no longer than maxUpdate
//each update time (not total!) no longer than maxUpdate
for(Flowfield data : threadList){
updateFrontier(data, maxUpdate / threadList.size);
//TODO implement timeouts... or don't
/*
//remove flowfields that have 'timed out' so they can be garbage collected and no longer waste space
if(data.refreshRate > 0 && Time.timeSinceMillis(data.lastUpdateTime) > fieldTimeout){
//make sure it doesn't get removed twice
data.lastUpdateTime = Time.millis();
//if it's dirty and there is nothing to update, begin updating once more
if(data.dirty && data.frontier.size == 0){
updateTargets(data);
data.dirty = false;
}
Team team = data.team;
Core.app.post(() -> {
//remove its used state
if(fieldMap[team.id] != null){
fieldMap[team.id].remove(data.target);
fieldMapUsed[team.id].remove(data.target);
}
//remove from main thread list
mainList.remove(data);
});
queue.post(() -> {
//remove from this thread list with a delay
threadList.remove(data);
});
}*/
updateFrontier(data, maxUpdate);
}
}
@@ -265,8 +324,7 @@ public class Pathfinder implements Runnable{
synchronized(path.targets){
//make sure the position actually changed
if(!(path.targets.size == 1 && tmpArray.size == 1 && path.targets.first() == tmpArray.first())){
path.targets.clear();
path.getPositions(path.targets);
path.updateTargetPositions();
//queue an update
queue.post(() -> updateTargets(path));
@@ -274,8 +332,10 @@ public class Pathfinder implements Runnable{
}
}
int[][] values = path.weights;
int value = values[tile.x][tile.y];
//use complete weights if possible; these contain a complete flow field that is not being updated
int[] values = path.hasComplete ? path.completeWeights : path.weights;
int apos = tile.array();
int value = values[apos];
Tile current = null;
int tl = 0;
@@ -285,42 +345,20 @@ public class Pathfinder implements Runnable{
Tile other = world.tile(dx, dy);
if(other == null) continue;
if(values[dx][dy] < value && (current == null || values[dx][dy] < tl) && path.passable(dx, dy) &&
!(point.x != 0 && point.y != 0 && (!path.passable(tile.x + point.x, tile.y) || !path.passable(tile.x, tile.y + point.y)))){ //diagonal corner trap
int packed = world.packArray(dx, dy);
if(values[packed] < value && (current == null || values[packed] < tl) && path.passable(packed) &&
!(point.x != 0 && point.y != 0 && (!path.passable(world.packArray(tile.x + point.x, tile.y)) || !path.passable(world.packArray(tile.x, tile.y + point.y))))){ //diagonal corner trap
current = other;
tl = values[dx][dy];
tl = values[packed];
}
}
if(current == null || tl == impassable) return tile;
if(current == null || tl == impassable || (path.cost == costTypes.items[costGround] && current.dangerous() && !tile.dangerous())) return tile;
return current;
}
/**
* Clears the frontier, increments the search and sets up all flow sources.
* This only occurs for active teams.
*/
private void updateTargets(Flowfield path, int x, int y){
if(!Structs.inBounds(x, y, path.weights)) return;
if(path.weights[x][y] == 0){
//this was a previous target
path.frontier.clear();
}else if(!path.frontier.isEmpty()){
//skip if this path is processing
return;
}
//update cost of the tile TODO maybe only update the cost when it's not passable
path.weights[x][y] = path.cost.getCost(path.team, tiles[x][y]);
//clear frontier to prevent contamination
path.frontier.clear();
updateTargets(path);
}
/** Increments the search and sets up flow sources. Does not change the frontier. */
private void updateTargets(Flowfield path){
@@ -331,18 +369,16 @@ public class Pathfinder implements Runnable{
//add targets
for(int i = 0; i < path.targets.size; i++){
int pos = path.targets.get(i);
int tx = Point2.x(pos), ty = Point2.y(pos);
path.weights[tx][ty] = 0;
path.searches[tx][ty] = path.search;
path.weights[pos] = 0;
path.searches[pos] = path.search;
path.frontier.addFirst(pos);
}
}
}
private void preloadPath(Flowfield path){
path.targets.clear();
path.getPositions(path.targets);
path.updateTargetPositions();
registerPath(path);
updateFrontier(path, -1);
}
@@ -354,7 +390,7 @@ public class Pathfinder implements Runnable{
*/
private void registerPath(Flowfield path){
path.lastUpdateTime = Time.millis();
path.setup(tiles.length, tiles[0].length);
path.setup(tiles.length);
threadList.add(path);
@@ -362,28 +398,29 @@ public class Pathfinder implements Runnable{
Core.app.post(() -> mainList.add(path));
//fill with impassables by default
for(int x = 0; x < world.width(); x++){
for(int y = 0; y < world.height(); y++){
path.weights[x][y] = impassable;
}
for(int i = 0; i < tiles.length; i++){
path.weights[i] = impassable;
}
//add targets
for(int i = 0; i < path.targets.size; i++){
int pos = path.targets.get(i);
path.weights[Point2.x(pos)][Point2.y(pos)] = 0;
path.weights[pos] = 0;
path.frontier.addFirst(pos);
}
}
/** Update the frontier for a path. Pathfinding thread only. */
private void updateFrontier(Flowfield path, long nsToRun){
boolean hadAny = path.frontier.size > 0;
long start = Time.nanos();
while(path.frontier.size > 0 && (nsToRun < 0 || Time.timeSinceNanos(start) <= nsToRun)){
Tile tile = world.tile(path.frontier.removeLast());
if(tile == null || path.weights == null) return; //something went horribly wrong, bail
int cost = path.weights[tile.x][tile.y];
int counter = 0;
while(path.frontier.size > 0){
int tile = path.frontier.removeLast();
if(path.weights == null) return; //something went horribly wrong, bail
int cost = path.weights[tile];
//pathfinding overflowed for some reason, time to bail. the next block update will handle this, hopefully
if(path.frontier.size >= world.width() * world.height()){
@@ -394,47 +431,79 @@ public class Pathfinder implements Runnable{
if(cost != impassable){
for(Point2 point : Geometry.d4){
int dx = tile.x + point.x, dy = tile.y + point.y;
int dx = (tile % wwidth) + point.x, dy = (tile / wwidth) + point.y;
if(dx < 0 || dy < 0 || dx >= tiles.length || dy >= tiles[0].length) continue;
if(dx < 0 || dy < 0 || dx >= wwidth || dy >= wheight) continue;
int otherCost = path.cost.getCost(path.team, tiles[dx][dy]);
int newPos = tile + point.x + point.y * wwidth;
int otherCost = path.cost.getCost(path.team.id, tiles[newPos]);
if((path.weights[dx][dy] > cost + otherCost || path.searches[dx][dy] < path.search) && otherCost != impassable){
path.frontier.addFirst(Point2.pack(dx, dy));
path.weights[dx][dy] = cost + otherCost;
path.searches[dx][dy] = (short)path.search;
if((path.weights[newPos] > cost + otherCost || path.searches[newPos] < path.search) && otherCost != impassable){
path.frontier.addFirst(newPos);
path.weights[newPos] = cost + otherCost;
path.searches[newPos] = (short)path.search;
}
}
}
//every N iterations, check the time spent - this prevents extra calls to nano time, which itself is slow
if(nsToRun >= 0 && (counter++) >= 200){
counter = 0;
if(Time.timeSinceNanos(start) >= nsToRun){
return;
}
}
}
//there WERE some things in the frontier, but now they are gone, so the path is done; copy over latest data
if(hadAny && path.frontier.size == 0){
System.arraycopy(path.weights, 0, path.completeWeights, 0, path.weights.length);
path.hasComplete = true;
}
}
public static class EnemyCoreField extends Flowfield{
private final static BlockFlag[] randomTargets = {storage, generator, launchPad, factory, repair, battery, reactor, drill};
private Rand rand = new Rand();
@Override
protected void getPositions(IntSeq out){
for(Tile other : indexer.getEnemy(team, BlockFlag.core)){
out.add(other.pos());
if(state.rules.randomWaveAI && team == state.rules.waveTeam){
rand.setSeed(state.rules.waves ? state.wave : (int)(state.tick / (5400)) + hashCode());
//maximum amount of different target flag types they will attack
int max = 1;
for(int attempt = 0; attempt < 5 && max > 0; attempt++){
var targets = indexer.getEnemy(team, randomTargets[rand.random(randomTargets.length - 1)]);
if(!targets.isEmpty()){
boolean any = false;
for(Building other : targets){
if((other.items != null && other.items.any()) || other.status() != BlockStatus.noInput){
out.add(other.tile.array());
any = true;
}
}
if(any){
max --;
}
}
}
}
for(Building other : indexer.getEnemy(team, BlockFlag.core)){
out.add(other.tile.array());
}
//spawn points are also enemies.
if(state.rules.waves && team == state.rules.defaultTeam){
for(Tile other : spawner.getSpawns()){
out.add(other.pos());
out.add(other.array());
}
}
}
}
public static class RallyField extends Flowfield{
@Override
protected void getPositions(IntSeq out){
for(Tile other : indexer.getAllied(team, BlockFlag.rally)){
out.add(other.pos());
}
}
}
public static class PositionTarget extends Flowfield{
public final Position position;
@@ -445,7 +514,7 @@ public class Pathfinder implements Runnable{
@Override
public void getPositions(IntSeq out){
out.add(Point2.pack(World.toTile(position.getX()), World.toTile(position.getY())));
out.add(world.packArray(World.toTile(position.getX()), World.toTile(position.getY())));
}
}
@@ -460,11 +529,18 @@ public class Pathfinder implements Runnable{
protected Team team = Team.derelict;
/** Function for calculating path cost. Set before using. */
protected PathCost cost = costTypes.get(costGround);
/** Whether there are valid weights in the complete array. */
protected volatile boolean hasComplete;
/** If true, this flow field needs updating. This flag is only set to false once the flow field finishes and the weights are copied over. */
protected boolean dirty = false;
/** costs of getting to a specific tile */
public int[][] weights;
public int[] weights;
/** search IDs of each position - the highest, most recent search is prioritized and overwritten */
public int[][] searches;
public int[] searches;
/** the last "complete" weights of this tilemap. */
public int[] completeWeights;
/** search frontier, these are Pos objects */
IntQueue frontier = new IntQueue();
/** all target positions; these positions have a cost of 0, and must be synchronized on! */
@@ -476,23 +552,35 @@ public class Pathfinder implements Runnable{
/** whether this flow field is ready to be used */
boolean initialized;
void setup(int width, int height){
this.weights = new int[width][height];
this.searches = new int[width][height];
this.frontier.ensureCapacity((width + height) * 3);
void setup(int length){
this.weights = new int[length];
this.searches = new int[length];
this.completeWeights = new int[length];
this.frontier.ensureCapacity((length) / 4);
this.initialized = true;
}
protected boolean passable(int x, int y){
return cost.getCost(team, pathfinder.tiles[x][y]) != impassable;
public boolean hasCompleteWeights(){
return hasComplete && completeWeights != null;
}
public void updateTargetPositions(){
targets.clear();
getPositions(targets);
}
protected boolean passable(int pos){
int amount = cost.getCost(team.id, pathfinder.tiles[pos]);
//edge case: naval reports costs of 6000+ for non-liquids, even though they are not technically passable
return amount != impassable && !(cost == costTypes.get(costNaval) && amount >= 6000);
}
/** Gets targets to pathfind towards. This must run on the main thread. */
protected abstract void getPositions(IntSeq out);
}
interface PathCost{
int getCost(Team traversing, int tile);
public interface PathCost{
int getCost(int team, int tile);
}
/** Holds a copy of tile data for a specific tile position. */
@@ -514,9 +602,15 @@ public class Pathfinder implements Runnable{
boolean nearGround;
//whether this block is near a solid object
boolean nearSolid;
//whether this block is near a block that is solid for legged units
boolean nearLegSolid;
//whether this block is deep / drownable
boolean deep;
//whether the floor damages
boolean damages;
//whether all tiles nearby are deep
boolean allDeep;
//block teamPassable is true
boolean teamPassable;
}
}
+353
View File
@@ -0,0 +1,353 @@
package mindustry.ai;
import arc.*;
import arc.graphics.g2d.*;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.*;
import mindustry.ai.types.*;
import mindustry.content.*;
import mindustry.core.*;
import mindustry.entities.*;
import mindustry.game.EventType.*;
import mindustry.game.Teams.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.logic.*;
import mindustry.ui.*;
import mindustry.world.*;
import mindustry.world.blocks.defense.turrets.BaseTurret.*;
import mindustry.world.blocks.defense.turrets.*;
import mindustry.world.blocks.storage.*;
import mindustry.world.blocks.storage.CoreBlock.*;
import mindustry.world.meta.*;
public class RtsAI{
static final Seq<Building> targets = new Seq<>();
static final Seq<Unit> squad = new Seq<>(false);
static final IntSet used = new IntSet();
static final IntSet assignedTargets = new IntSet(), invalidTarget = new IntSet();
static final float squadRadius = 140f;
static final int timeUpdate = 0, timerSpawn = 1, maxTargetsChecked = 15;
//in order of priority??
static final BlockFlag[] flags = {BlockFlag.generator, BlockFlag.factory, BlockFlag.core, BlockFlag.battery, BlockFlag.drill};
static final ObjectFloatMap<Building> weights = new ObjectFloatMap<>();
static final boolean debug = OS.hasProp("mindustry.debug");
final Interval timer = new Interval(10);
final TeamData data;
final ObjectSet<Building> damagedSet = new ObjectSet<>();
final Seq<Building> damaged = new Seq<>(false);
//must be static, as this class can get instantiated many times; event listeners are hard to clean up
static{
Events.on(BuildDamageEvent.class, e -> {
if(e.build.team.rules().rtsAi){
var ai = e.build.team.data().rtsAi;
if(ai != null){
ai.damagedSet.add(e.build);
}
}
});
}
public RtsAI(TeamData data){
this.data = data;
timer.reset(0, Mathf.random(60f * 2f));
//TODO remove: debugging!
if(debug){
Events.run(Trigger.draw, () -> {
Draw.draw(Layer.overlayUI, () -> {
float s = Fonts.outline.getScaleX();
Fonts.outline.getData().setScale(0.5f);
for(var target : weights){
Fonts.outline.draw("[sky]" + Strings.fixed(target.value, 2), target.key.x, target.key.y, Align.center);
}
Fonts.outline.getData().setScale(s);
});
});
}
}
public void update(){
if(timer.get(timeUpdate, 60f * 2f)){
assignSquads();
checkBuilding();
}
}
//TODO atrocious implementation
void checkBuilding(){
if(data.team.rules().aiCoreSpawn && timer.get(timerSpawn, 60 * 7f) && data.hasCore()){
CoreBlock block = (CoreBlock)data.core().block;
int coreUnits = data.countType(block.unitType);
//create AI core unit(s) at random cores
if(coreUnits < data.cores.size){
Unit unit = block.unitType.create(data.team);
unit.set(data.cores.random());
unit.add();
Fx.spawn.at(unit);
}
}
}
void assignSquads(){
assignedTargets.clear();
used.clear();
damaged.addAll(damagedSet);
damagedSet.clear();
boolean didDefend = false;
for(var unit : data.units){
if(used.add(unit.id) && unit.isCommandable() && !unit.command().hasCommand() && !unit.command().isAttacking()){
squad.clear();
float rad = squadRadius + unit.hitSize*1.5f;
data.tree().intersect(unit.x - rad/2f, unit.y - rad/2f, rad, rad, squad);
squad.truncate(data.team.rules().rtsMaxSquad);
//remove overlapping squads
squad.removeAll(u -> (u != unit && used.contains(u.id)) || !u.isCommandable() || u.command().hasCommand() || ((u.flag == 0) != (unit.flag == 0)));
//mark used so other squads can't steal them
for(var item : squad){
used.add(item.id);
}
//TODO flawed, squads
if(handleSquad(squad, !didDefend)){
didDefend = true;
}
}
}
damaged.clear();
}
boolean handleSquad(Seq<Unit> units, boolean noDefenders){
if(units.isEmpty()) return false;
float health = 0f, dps = 0f;
float ax = 0f, ay = 0f;
boolean targetAir = true, targetGround = true;
for(var unit : units){
if(!unit.type.targetAir) targetAir = false;
if(!unit.type.targetGround) targetGround = false;
ax += unit.x;
ay += unit.y;
health += unit.health;
dps += unit.type.dpsEstimate;
}
ax /= units.size;
ay /= units.size;
if(debug){
Vars.ui.showLabel("Squad: " + units.size, 2f, ax, ay);
}
Building defend = null;
boolean defendingCore = false;
//there is something to defend, see if it's worth the time
if(damaged.size > 0){
//TODO do the weights matter at all?
//for(var build : damaged){
//float w = estimateStats(ax, ay, dps, health);
//weights.put(build, w);
//}
//screw you java
float aax = ax, aay = ay;
Building best = damaged.min(b -> {
//rush to core IMMEDIATELY
if(b instanceof CoreBuild){
return -999999f;
}
return b.dst(aax, aay);
});
//defend when close, or this is the only squad defending
//TODO will always rush to defense no matter what
if(best != null && (best instanceof CoreBuild || (units.size >= data.team.rules().rtsMinSquad || (units.size > 0 && units.first().flag != 0)) || best.within(ax, ay, 1000f))){
defend = best;
if(debug){
Vars.ui.showLabel("Defend, dst = " + (int)(best.dst(ax, ay)), 8f, best.x, best.y);
}
if(best instanceof CoreBuild){
defendingCore = true;
}
}
}
boolean tair = targetAir, tground = targetGround;
//find aggressor, or else, the thing being attacked
Vec2 defendPos = null;
Teamc defendTarget = null;
if(defend != null){
float checkRange = 350f;
//TODO could be made faster by storing bullet shooter
Unit aggressor = Units.closestEnemy(data.team, defend.x, defend.y, checkRange, u -> u.checkTarget(tair, tground));
if(aggressor != null){
//do not target it directly - target the position?
//defendTarget = aggressor;
defendPos = new Vec2(aggressor.x, aggressor.y);
defendTarget = aggressor;
}else if(false){ //TODO currently ignored, no use defending against nothing
//should it even go there if there's no aggressor found?
Tile closest = defend.findClosestEdge(units.first(), Tile::solid);
if(closest != null){
defendPos = new Vec2(closest.worldx(), closest.worldy());
}
}else{
float mindst = Float.MAX_VALUE;
Building build = null;
//find closest turret to attack.
for(var turret : Vars.indexer.getEnemy(data.team, BlockFlag.turret)){
if(turret.within(defend, ((Ranged)turret).range())){
float dst = turret.dst2(defend);
if(dst < mindst){
mindst = dst;
build = turret;
}
}
}
if(build != null){
defendTarget = build;
}
}
}
boolean anyDefend = defendPos != null || defendTarget != null;
invalidTarget.clear();
for(var unit : squad){
if(unit.controller() instanceof CommandAI ai){
invalidTarget.addAll(ai.unreachableBuildings);
}
}
var build = anyDefend ? null : findTarget(ax, ay, units.size, dps, health, units.first().flag == 0, units.first().isFlying());
if(build != null || anyDefend){
for(var unit : units){
if(unit.isCommandable() && !unit.command().hasCommand()){
if(defendPos != null && !unit.isPathImpassable(World.toTile(defendPos.x), World.toTile(defendPos.y))){
unit.command().commandPosition(defendPos, true);
}else{
//TODO stopAtTarget parameter could be false, could be tweaked
unit.command().commandTarget(defendTarget == null ? build : defendTarget, defendTarget != null);
}
//assign a flag, so it will be "mobilized" more easily later
if(!defendingCore){
unit.flag = 1;
}
}
}
}
return anyDefend;
}
@Nullable Building findTarget(float x, float y, int total, float dps, float health, boolean checkWeight, boolean air){
if(total < data.team.rules().rtsMinSquad) return null;
//flag priority?
//1. generator
//2. factory
//3. core
targets.clear();
for(var flag : flags){
targets.addAll(Vars.indexer.getEnemy(data.team, flag));
}
targets.removeAll(b -> assignedTargets.contains(b.id) || invalidTarget.contains(b.pos()));
if(targets.size == 0) return null;
weights.clear();
//only check a maximum number of targets to prevent hammering the CPU with estimateStats calls
targets.shuffle();
targets.truncate(maxTargetsChecked);
for(var target : targets){
weights.put(target, estimateStats(x, y, target.x, target.y, dps, health, air));
}
var result = targets.min(
Structs.comps(
//weight is most important?
Structs.comparingFloat(b -> (1f - weights.get(b, 0f)) + b.dst(x, y)/10000f),
//then distance TODO why weight above
Structs.comparingFloat(b -> b.dst2(x, y))
)
);
float weight = weights.get(result, 0f);
if(checkWeight && weight < data.team.rules().rtsMinWeight && total < Units.getCap(data.team)){
return null;
}
assignedTargets.add(result.id);
return result;
}
//TODO extremely slow especially with many squads.
float estimateStats(float fromX, float fromY, float x, float y, float selfDps, float selfHealth, boolean air){
float[] health = {0f}, dps = {0f};
float extraRadius = 50f;
for(var turret : Vars.indexer.getEnemy(data.team, BlockFlag.turret)){
if(turret instanceof BaseTurretBuild t && turret.block instanceof Turret tb && ((tb.targetAir && air) || (tb.targetGround && !air)) && Intersector.distanceSegmentPoint(fromX, fromY, x, y, t.x, t.y) <= t.range() + extraRadius){
health[0] += t.health;
dps[0] += t.estimateDps();
}
}
Tmp.r1.set(fromX, fromY, x - fromX, y - fromY).normalize().grow(140f * 2f);
//add on extra radius, assume unit range is below that...?
Units.nearbyEnemies(data.team, Tmp.r1, other -> {
if(Intersector.distanceSegmentPoint(fromX, fromY, x, y, other.x, other.y) <= other.range() + extraRadius){
health[0] += other.health;
dps[0] += other.type.dpsEstimate;
}
});
float hp = health[0], dp = dps[0];
float timeDestroyOther = Mathf.zero(selfDps, 0.001f) ? Float.POSITIVE_INFINITY : hp / selfDps;
float timeDestroySelf = Mathf.zero(dp) ? Float.POSITIVE_INFINITY : selfHealth / dp;
//other can never be destroyed | other destroys self instantly
if(Float.isInfinite(timeDestroyOther) || Mathf.zero(timeDestroySelf)) return 0f;
//self can never be destroyed | self destroys other instantly
if(Float.isInfinite(timeDestroySelf) || Mathf.zero(timeDestroyOther)) return 100000f;
//examples:
// self 10 sec / other 10 sec -> can destroy target with 100 % losses -> returns 1
// self 5 sec / other 10 sec -> can destroy about half of other -> returns 0.5 (needs to be 2x stronger to defeat)
return timeDestroySelf / timeDestroyOther;
}
}
+119
View File
@@ -0,0 +1,119 @@
package mindustry.ai;
import arc.*;
import arc.func.*;
import arc.scene.style.*;
import arc.struct.*;
import arc.util.*;
import mindustry.ai.types.*;
import mindustry.ctype.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
import mindustry.input.*;
/** Defines a pattern of behavior that an RTS-controlled unit should follow. Shows up in the command UI. */
public class UnitCommand extends MappableContent{
/** @deprecated now a content type, use the methods in Vars.content instead */
@Deprecated
public static final Seq<UnitCommand> all = new Seq<>();
public static UnitCommand moveCommand, repairCommand, rebuildCommand, assistCommand, mineCommand, boostCommand, enterPayloadCommand, loadUnitsCommand, loadBlocksCommand, unloadPayloadCommand, loopPayloadCommand;
/** Name of UI icon (from Icon class). */
public final String icon;
/** Controller that this unit will use when this command is used. Return null for "default" behavior. */
public final Func<Unit, AIController> controller;
/** If true, this unit will automatically switch away to the move command when given a position. */
public boolean switchToMove = true;
/** Whether to draw the movement/attack target. */
public boolean drawTarget = false;
/** Whether to reset targets when switching to or from this command. */
public boolean resetTarget = true;
/** */
public boolean exactArrival = false;
/** Key to press for this command. */
public @Nullable Binding keybind = null;
public UnitCommand(String name, String icon, Func<Unit, AIController> controller){
super(name);
this.icon = icon;
this.controller = controller == null ? u -> null : controller;
all.add(this);
}
public UnitCommand(String name, String icon, Binding keybind, Func<Unit, AIController> controller){
this(name, icon, controller);
this.keybind = keybind;
}
public String localized(){
return Core.bundle.get("command." + name);
}
public TextureRegionDrawable getIcon(){
return Icon.icons.get(icon, Icon.cancel);
}
public char getEmoji() {
return (char)Iconc.codes.get(icon, Iconc.cancel);
}
@Override
public ContentType getContentType(){
return ContentType.unitCommand;
}
@Override
public String toString(){
return "UnitCommand:" + name;
}
public static void loadAll(){
moveCommand = new UnitCommand("move", "right", Binding.unit_command_move, null){{
drawTarget = true;
resetTarget = false;
}};
repairCommand = new UnitCommand("repair", "modeSurvival", Binding.unit_command_repair, u -> new RepairAI());
rebuildCommand = new UnitCommand("rebuild", "hammer", Binding.unit_command_rebuild, u -> new BuilderAI());
assistCommand = new UnitCommand("assist", "players", Binding.unit_command_assist, u -> {
var ai = new BuilderAI();
ai.onlyAssist = true;
return ai;
});
mineCommand = new UnitCommand("mine", "production", Binding.unit_command_mine, u -> new MinerAI());
boostCommand = new UnitCommand("boost", "up", Binding.unit_command_boost, u -> new BoostAI()){{
switchToMove = false;
drawTarget = true;
resetTarget = false;
}};
enterPayloadCommand = new UnitCommand("enterPayload", "downOpen", Binding.unit_command_enter_payload, null){{
switchToMove = false;
drawTarget = true;
resetTarget = false;
}};
loadUnitsCommand = new UnitCommand("loadUnits", "upload", Binding.unit_command_load_units, null){{
switchToMove = false;
drawTarget = true;
resetTarget = false;
}};
loadBlocksCommand = new UnitCommand("loadBlocks", "up", Binding.unit_command_load_blocks, null){{
switchToMove = false;
drawTarget = true;
resetTarget = false;
exactArrival = true;
}};
unloadPayloadCommand = new UnitCommand("unloadPayload", "download", Binding.unit_command_unload_payload, null){{
switchToMove = false;
drawTarget = true;
resetTarget = false;
}};
loopPayloadCommand = new UnitCommand("loopPayload", "resize", Binding.unit_command_loop_payload, null){{
switchToMove = false;
drawTarget = true;
resetTarget = false;
}};
}
}
+186
View File
@@ -0,0 +1,186 @@
package mindustry.ai;
import arc.*;
import arc.graphics.*;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.*;
import mindustry.ai.Pathfinder.*;
import mindustry.async.*;
import mindustry.content.*;
import mindustry.core.*;
import mindustry.gen.*;
public class UnitGroup{
public Seq<Unit> units = new Seq<>();
public int collisionLayer;
public volatile float[] positions, originalPositions;
public volatile boolean valid;
public void calculateFormation(Vec2 dest, int collisionLayer){
this.collisionLayer = collisionLayer;
float cx = 0f, cy = 0f;
for(Unit unit : units){
cx += unit.x;
cy += unit.y;
}
cx /= units.size;
cy /= units.size;
positions = new float[units.size * 2];
//all positions are relative to the center
for(int i = 0; i < units.size; i ++){
Unit unit = units.get(i);
positions[i * 2] = unit.x - cx;
positions[i * 2 + 1] = unit.y - cy;
unit.command().groupIndex = i;
}
//run on new thread to prevent stutter
Vars.mainExecutor.submit(() -> {
//unused space between circles that needs to be reached for compression to end
float maxSpaceUsage = 0.7f;
boolean compress = true;
int compressionIterations = 0;
int physicsIterations = 0;
int totalIterations = 0;
int maxPhysicsIterations = Math.min(1 + (int)(Math.pow(units.size, 0.65) / 10), 6);
//yep, new allocations, because this is a new thread.
IntQuadTree tree = new IntQuadTree(new Rect(0f, 0f, Vars.world.unitWidth(), Vars.world.unitHeight()),
(index, hitbox) -> hitbox.setCentered(positions[index * 2], positions[index * 2 + 1], units.get(index).hitSize));
IntSeq tmpseq = new IntSeq();
Vec2 v1 = new Vec2();
Vec2 v2 = new Vec2();
//this algorithm basically squeezes all the circle colliders together, then proceeds to simulate physics to push them apart across several iterations.
//it's rather slow, but shouldn't be too much of an issue when run in a different thread
while(totalIterations++ < 40 && physicsIterations < maxPhysicsIterations){
float spaceUsed = 0f;
if(compress){
compressionIterations ++;
float maxDst = 1f, totalArea = 0f;
for(int a = 0; a < units.size; a ++){
v1.set(positions[a * 2], positions[a * 2 + 1]).lerp(v2.set(0f, 0f), 0.3f);
positions[a * 2] = v1.x;
positions[a * 2 + 1] = v1.y;
float rad = units.get(a).hitSize * Vars.unitCollisionRadiusScale;
maxDst = Math.max(maxDst, v1.dst(0f, 0f) + rad);
totalArea += Mathf.PI * rad * rad;
}
//total area of bounding circle
float boundingArea = Mathf.PI * maxDst * maxDst;
spaceUsed = totalArea / boundingArea;
//ex: 60% (0.6) of the total area is used, this will not be enough to satisfy a maxSpaceUsage of 70% (0.7)
compress = spaceUsed <= maxSpaceUsage && compressionIterations < 20;
}
//uncompress units
if(!compress || spaceUsed > 0.5f){
physicsIterations++;
tree.clear();
for(int a = 0; a < units.size; a++){
tree.insert(a);
}
for(int a = 0; a < units.size; a++){
Unit unit = units.get(a);
float x = positions[a * 2], y = positions[a * 2 + 1], radius = unit.hitSize/2f;
tmpseq.clear();
tree.intersect(x - radius, y - radius, radius * 2f, radius * 2f, tmpseq);
for(int res = 0; res < tmpseq.size; res ++){
int b = tmpseq.items[res];
//simulate collision physics
if(a != b){
float ox = positions[b * 2], oy = positions[b * 2 + 1];
Unit other = units.get(b);
float rs = (radius + other.hitSize/2f) * 1.2f;
float dst = Mathf.dst(x, y, ox, oy);
if(dst < rs){
v2.set(x - ox, y - oy).setLength(rs - dst);
float mass1 = unit.hitSize, mass2 = other.hitSize;
float ms = mass1 + mass2;
float m1 = mass2 / ms, m2 = mass1 / ms;
float scl = 1f;
positions[a * 2] += v2.x * m1 * scl;
positions[a * 2 + 1] += v2.y * m1 * scl;
positions[b * 2] -= v2.x * m2 * scl;
positions[b * 2 + 1] -= v2.y * m2 * scl;
}
}
}
}
}
}
originalPositions = positions.clone();
//raycast from the destination to the offset to make sure it's reachable
for(int a = 0; a < units.size; a ++){
updateRaycast(a, dest, v1);
}
valid = true;
if(ControlPathfinder.showDebug){
Core.app.post(() -> {
for(int i = 0; i < units.size; i ++){
float x = positions[i * 2], y = positions[i * 2 + 1];
Fx.placeBlock.at(x + dest.x, y + dest.y, 1f, Color.green);
}
});
}
});
}
public void updateRaycast(int index, Vec2 dest){
updateRaycast(index, dest, Tmp.v1);
}
private void updateRaycast(int index, Vec2 dest, Vec2 v1){
if(collisionLayer != PhysicsProcess.layerFlying){
//coordinates in world space
float
x = originalPositions[index * 2] + dest.x,
y = originalPositions[index * 2 + 1] + dest.y;
Unit unit = units.get(index);
PathCost cost = unit.type.pathCost;
int res = ControlPathfinder.raycastFastAvoid(unit.team.id, cost, World.toTile(dest.x), World.toTile(dest.y), World.toTile(x), World.toTile(y));
//collision found, make the destination the point right before the collision
if(res != 0){
v1.set(Point2.x(res) * Vars.tilesize - dest.x, Point2.y(res) * Vars.tilesize - dest.y);
v1.setLength(Math.max(v1.len() - Vars.tilesize - 4f, 0));
positions[index * 2] = v1.x;
positions[index * 2 + 1] = v1.y;
}
if(ControlPathfinder.showDebug){
Core.app.post(() -> Fx.debugLine.at(unit.x, unit.y, 0f, Color.green, new Vec2[]{new Vec2(dest.x, dest.y), new Vec2(x, y)}));
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package mindustry.ai;
import arc.*;
import arc.scene.style.*;
import arc.struct.*;
import arc.util.*;
import mindustry.ctype.*;
import mindustry.gen.*;
import mindustry.input.*;
public class UnitStance extends MappableContent{
/** @deprecated now a content type, use the methods in Vars.content instead */
@Deprecated
public static final Seq<UnitStance> all = new Seq<>();
public static UnitStance stop, shoot, holdFire, pursueTarget, patrol, ram;
/** Name of UI icon (from Icon class). */
public final String icon;
/** Key to press for this stance. */
public @Nullable Binding keybind = null;
public UnitStance(String name, String icon, Binding keybind){
super(name);
this.icon = icon;
this.keybind = keybind;
all.add(this);
}
public String localized(){
return Core.bundle.get("stance." + name);
}
public TextureRegionDrawable getIcon(){
return Icon.icons.get(icon, Icon.cancel);
}
public char getEmoji() {
return (char) Iconc.codes.get(icon, Iconc.cancel);
}
@Override
public ContentType getContentType(){
return ContentType.unitStance;
}
@Override
public String toString(){
return "UnitStance:" + name;
}
public static void loadAll(){
stop = new UnitStance("stop", "cancel", Binding.cancel_orders);
shoot = new UnitStance("shoot", "commandAttack", Binding.unit_stance_shoot);
holdFire = new UnitStance("holdfire", "none", Binding.unit_stance_hold_fire);
pursueTarget = new UnitStance("pursuetarget", "right", Binding.unit_stance_pursue_target);
patrol = new UnitStance("patrol", "refresh", Binding.unit_stance_patrol);
ram = new UnitStance("ram", "rightOpen", Binding.unit_stance_ram);
}
}
+75 -24
View File
@@ -19,8 +19,9 @@ import mindustry.world.*;
import static mindustry.Vars.*;
public class WaveSpawner{
private static final float margin = 40f, coreMargin = tilesize * 2f, maxSteps = 30;
private static final float margin = 0f, coreMargin = tilesize * 2f, maxSteps = 30;
private int tmpCount;
private Seq<Tile> spawns = new Seq<>();
private boolean spawning = false;
private boolean any = false;
@@ -55,7 +56,7 @@ public class WaveSpawner{
public void spawnEnemies(){
spawning = true;
eachGroundSpawn((spawnX, spawnY, doShockwave) -> {
eachGroundSpawn(-1, (spawnX, spawnY, doShockwave) -> {
if(doShockwave){
doShockwave(spawnX, spawnY);
}
@@ -65,23 +66,33 @@ public class WaveSpawner{
if(group.type == null) continue;
int spawned = group.getSpawned(state.wave - 1);
if(spawned == 0) continue;
if(state.isCampaign()){
//when spawning a boss, round down, so 1.5x (hard) * 1 boss does not result in 2 bosses
spawned = Math.max(1, group.effect == StatusEffects.boss ?
(int)(spawned * state.getPlanet().campaignRules.difficulty.enemySpawnMultiplier) :
Mathf.round(spawned * state.getPlanet().campaignRules.difficulty.enemySpawnMultiplier));
}
int spawnedf = spawned;
if(group.type.flying){
float spread = margin / 1.5f;
eachFlyerSpawn((spawnX, spawnY) -> {
for(int i = 0; i < spawned; i++){
eachFlyerSpawn(group.spawn, (spawnX, spawnY) -> {
for(int i = 0; i < spawnedf; i++){
Unit unit = group.createUnit(state.rules.waveTeam, state.wave - 1);
unit.set(spawnX + Mathf.range(spread), spawnY + Mathf.range(spread));
unit.add();
spawnEffect(unit);
}
});
}else{
float spread = tilesize * 2;
eachGroundSpawn((spawnX, spawnY, doShockwave) -> {
eachGroundSpawn(group.spawn, (spawnX, spawnY, doShockwave) -> {
for(int i = 0; i < spawned; i++){
for(int i = 0; i < spawnedf; i++){
Tmp.v1.rnd(spread);
Unit unit = group.createUnit(state.rules.waveTeam, state.wave - 1);
@@ -92,7 +103,7 @@ public class WaveSpawner{
}
}
Time.runTask(121f, () -> spawning = false);
Time.run(121f, () -> spawning = false);
}
public void doShockwave(float x, float y){
@@ -101,12 +112,14 @@ public class WaveSpawner{
}
public void eachGroundSpawn(Intc2 cons){
eachGroundSpawn((x, y, shock) -> cons.get(World.toTile(x), World.toTile(y)));
eachGroundSpawn(-1, (x, y, shock) -> cons.get(World.toTile(x), World.toTile(y)));
}
private void eachGroundSpawn(SpawnConsumer cons){
private void eachGroundSpawn(int filterPos, SpawnConsumer cons){
if(state.hasSpawns()){
for(Tile spawn : spawns){
if(filterPos != -1 && filterPos != spawn.pos()) continue;
cons.accept(spawn.worldx(), spawn.worldy(), true);
}
}
@@ -114,6 +127,8 @@ public class WaveSpawner{
if(state.rules.attackMode && state.teams.isActive(state.rules.waveTeam) && !state.teams.playerCores().isEmpty()){
Building firstCore = state.teams.playerCores().first();
for(Building core : state.rules.waveTeam.cores()){
if(filterPos != -1 && filterPos != core.pos()) continue;
Tmp.v1.set(firstCore).sub(core).limit(coreMargin + core.block.size * tilesize /2f * Mathf.sqrt2);
boolean valid = false;
@@ -146,28 +161,51 @@ public class WaveSpawner{
}
}
private void eachFlyerSpawn(Floatc2 cons){
for(Tile tile : spawns){
float angle = Angles.angle(world.width() / 2, world.height() / 2, tile.x, tile.y);
private void eachFlyerSpawn(int filterPos, Floatc2 cons){
boolean airUseSpawns = state.rules.airUseSpawns;
float trns = Math.max(world.width(), world.height()) * Mathf.sqrt2 * tilesize;
float spawnX = Mathf.clamp(world.width() * tilesize / 2f + Angles.trnsx(angle, trns), -margin, world.width() * tilesize + margin);
float spawnY = Mathf.clamp(world.height() * tilesize / 2f + Angles.trnsy(angle, trns), -margin, world.height() * tilesize + margin);
cons.get(spawnX, spawnY);
for(Tile tile : spawns){
if(filterPos != -1 && filterPos != tile.pos()) continue;
if(!airUseSpawns){
float angle = Angles.angle(world.width() / 2f, world.height() / 2f, tile.x, tile.y);
float trns = Math.max(world.width(), world.height()) * Mathf.sqrt2 * tilesize;
float spawnX = Mathf.clamp(world.width() * tilesize / 2f + Angles.trnsx(angle, trns), -margin, world.width() * tilesize + margin);
float spawnY = Mathf.clamp(world.height() * tilesize / 2f + Angles.trnsy(angle, trns), -margin, world.height() * tilesize + margin);
cons.get(spawnX, spawnY);
}else{
cons.get(tile.worldx(), tile.worldy());
}
}
if(state.rules.attackMode && state.teams.isActive(state.rules.waveTeam)){
for(Building core : state.teams.get(state.rules.waveTeam).cores){
for(Building core : state.rules.waveTeam.data().cores){
if(filterPos != -1 && filterPos != core.pos()) continue;
cons.get(core.x, core.y);
}
}
}
public int countGroundSpawns(){
tmpCount = 0;
eachGroundSpawn((x, y) -> tmpCount ++);
return tmpCount;
}
public int countFlyerSpawns(){
tmpCount = 0;
eachFlyerSpawn(-1, (x, y) -> tmpCount ++);
return tmpCount;
}
public boolean isSpawning(){
return spawning && !net.client();
}
private void reset(){
public void reset(){
spawning = false;
spawns.clear();
for(Tile tile : world.tiles){
@@ -177,9 +215,21 @@ public class WaveSpawner{
}
}
private void spawnEffect(Unit unit){
Call.spawnEffect(unit.x, unit.y, unit.type);
Time.run(30f, unit::add);
/** Applies the standard wave spawn effects to a unit - invincibility, unmoving. */
public void spawnEffect(Unit unit){
spawnEffect(unit, unit.angleTo(world.width()/2f * tilesize, world.height()/2f * tilesize));
}
/** Applies the standard wave spawn effects to a unit - invincibility, unmoving. */
public void spawnEffect(Unit unit, float rotation){
unit.rotation = rotation;
unit.apply(StatusEffects.unmoving, 30f);
unit.apply(StatusEffects.invincible, 60f);
unit.add();
unit.unloaded();
Events.fire(new UnitSpawnEvent(unit));
Call.spawnEffect(unit.x, unit.y, unit.rotation, unit.type);
}
private interface SpawnConsumer{
@@ -187,8 +237,9 @@ public class WaveSpawner{
}
@Remote(called = Loc.server, unreliable = true)
public static void spawnEffect(float x, float y, UnitType type){
Fx.unitSpawn.at(x, y, 0f, type);
public static void spawnEffect(float x, float y, float rotation, UnitType u){
Fx.unitSpawn.at(x, y, rotation, u);
Time.run(30f, () -> Fx.spawn.at(x, y));
}
@@ -1,44 +0,0 @@
package mindustry.ai.formations;
import arc.struct.*;
/**
* {@code BoundedSlotAssignmentStrategy} is an abstract implementation of {@link SlotAssignmentStrategy} that supports roles.
* Generally speaking, there are hard and soft roles. Hard roles cannot be broken, soft roles can.
* <p>
* This abstract class provides an implementation of the {@link #calculateNumberOfSlots(Seq) calculateNumberOfSlots} method that
* is more general (and costly) than the simplified implementation in {@link FreeSlotAssignmentStrategy}. It scans the assignment
* list to find the number of filled slots, which is the highest slot number in the assignments.
* @author davebaol
*/
public abstract class BoundedSlotAssignmentStrategy implements SlotAssignmentStrategy{
@Override
public abstract void updateSlotAssignments(Seq<SlotAssignment> assignments);
@Override
public int calculateNumberOfSlots(Seq<SlotAssignment> assignments){
// Find the number of filled slots: it will be the
// highest slot number in the assignments
int filledSlots = -1;
for(int i = 0; i < assignments.size; i++){
SlotAssignment assignment = assignments.get(i);
if(assignment.slotNumber >= filledSlots) filledSlots = assignment.slotNumber;
}
// Add one to go from the index of the highest slot to the number of slots needed.
return filledSlots + 1;
}
@Override
public void removeSlotAssignment(Seq<SlotAssignment> assignments, int index){
int sn = assignments.get(index).slotNumber;
for(int i = 0; i < assignments.size; i++){
SlotAssignment sa = assignments.get(i);
if(sa.slotNumber >= sn) sa.slotNumber--;
}
assignments.remove(index);
}
}
@@ -1,51 +0,0 @@
package mindustry.ai.formations;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
public class DistanceAssignmentStrategy implements SlotAssignmentStrategy{
private final Vec3 vec = new Vec3();
private final FormationPattern form;
public DistanceAssignmentStrategy(FormationPattern form){
this.form = form;
}
@Override
public void updateSlotAssignments(Seq<SlotAssignment> assignments){
IntSeq slots = IntSeq.range(0, assignments.size);
for(SlotAssignment slot : assignments){
int mindex = 0;
float mcost = Float.MAX_VALUE;
for(int i = 0; i < slots.size; i++){
float cost = cost(slot.member, slots.get(i));
if(cost < mcost){
mcost = cost;
mindex = i;
}
}
slot.slotNumber = slots.get(mindex);
slots.removeIndex(mindex);
}
}
@Override
public int calculateNumberOfSlots(Seq<SlotAssignment> assignments){
return assignments.size;
}
@Override
public void removeSlotAssignment(Seq<SlotAssignment> assignments, int index){
assignments.remove(index);
}
float cost(FormationMember member, int slot){
form.calculateSlotLocation(vec, slot);
return Mathf.dst2(member.formationPos().x, member.formationPos().y, vec.x, vec.y);
}
}
@@ -1,224 +0,0 @@
package mindustry.ai.formations;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
/**
* A {@code Formation} coordinates the movement of a group of characters so that they retain some group organization. Characters
* belonging to a formation must implement the {@link FormationMember} interface. At its simplest, a formation can consist of
* moving in a fixed geometric pattern such as a V or line abreast, but it is not limited to that. Formations can also make use of
* the environment. Squads of characters can move between cover points using formation steering with only minor modifications, for
* example.
* <p>
* Formation motion is used in team sports games, squad-based games, real-time strategy games, and sometimes in first-person
* shooters, driving games, and action adventures too. It is a simple and flexible technique that is much quicker to write and
* execute and can produce much more stable behavior than collaborative tactical decision making.
* @author davebaol
*/
public class Formation{
/** A list of slots assignments. */
public Seq<SlotAssignment> slotAssignments;
/** The anchor point of this formation. */
public Vec3 anchor;
/** The formation pattern */
public FormationPattern pattern;
/** The strategy used to assign a member to his slot */
public SlotAssignmentStrategy slotAssignmentStrategy;
/** The formation motion moderator */
public FormationMotionModerator motionModerator;
private final Vec2 positionOffset;
private final Mat orientationMatrix = new Mat();
/** The location representing the drift offset for the currently filled slots. */
private final Vec3 driftOffset;
/**
* Creates a {@code Formation} for the specified {@code pattern} using a {@link FreeSlotAssignmentStrategy} and no motion
* moderator.
* @param anchor the anchor point of this formation, Cannot be {@code null}.
* @param pattern the pattern of this formation
* @throws IllegalArgumentException if the anchor point is {@code null}
*/
public Formation(Vec3 anchor, FormationPattern pattern){
this(anchor, pattern, new FreeSlotAssignmentStrategy(), null);
}
/**
* Creates a {@code Formation} for the specified {@code pattern} and {@code slotAssignmentStrategy} using no motion moderator.
* @param anchor the anchor point of this formation, Cannot be {@code null}.
* @param pattern the pattern of this formation
* @param slotAssignmentStrategy the strategy used to assign a member to his slot
* @throws IllegalArgumentException if the anchor point is {@code null}
*/
public Formation(Vec3 anchor, FormationPattern pattern, SlotAssignmentStrategy slotAssignmentStrategy){
this(anchor, pattern, slotAssignmentStrategy, null);
}
/**
* Creates a {@code Formation} for the specified {@code pattern}, {@code slotAssignmentStrategy} and {@code moderator}.
* @param anchor the anchor point of this formation, Cannot be {@code null}.
* @param pattern the pattern of this formation
* @param slotAssignmentStrategy the strategy used to assign a member to his slot
* @param motionModerator the motion moderator. Can be {@code null} if moderation is not needed
* @throws IllegalArgumentException if the anchor point is {@code null}
*/
public Formation(Vec3 anchor, FormationPattern pattern, SlotAssignmentStrategy slotAssignmentStrategy,
FormationMotionModerator motionModerator){
if(anchor == null) throw new IllegalArgumentException("The anchor point cannot be null");
this.anchor = anchor;
this.pattern = pattern;
this.slotAssignmentStrategy = slotAssignmentStrategy;
this.motionModerator = motionModerator;
this.slotAssignments = new Seq<>();
this.driftOffset = new Vec3();
this.positionOffset = new Vec2(anchor.x, anchor.y).cpy();
}
/** Updates the assignment of members to slots */
public void updateSlotAssignments(){
pattern.slots = slotAssignments.size;
// Apply the strategy to update slot assignments
slotAssignmentStrategy.updateSlotAssignments(slotAssignments);
// Set the newly calculated number of slots
pattern.slots = slotAssignmentStrategy.calculateNumberOfSlots(slotAssignments);
// Update the drift offset if a motion moderator is set
if(motionModerator != null) motionModerator.calculateDriftOffset(driftOffset, slotAssignments, pattern);
}
/**
* Changes the pattern of this formation and updates slot assignments if the number of member is supported by the given
* pattern.
* @param pattern the pattern to set
* @return {@code true} if the pattern has effectively changed; {@code false} otherwise.
*/
public boolean changePattern(FormationPattern pattern){
// Find out how many slots we have occupied
int occupiedSlots = slotAssignments.size;
// Check if the pattern supports one more slot
if(pattern.supportsSlots(occupiedSlots)){
this.pattern = pattern;
// Update the slot assignments and return success
updateSlotAssignments();
return true;
}
return false;
}
/** Much more efficient than adding a single member.
* @return number of members added. */
public int addMembers(Iterable<? extends FormationMember> members){
int added = 0;
for(FormationMember member : members){
if(pattern.supportsSlots(slotAssignments.size + 1)){
slotAssignments.add(new SlotAssignment(member, slotAssignments.size));
added ++;
}
}
updateSlotAssignments();
return added;
}
/**
* Adds a new member to the first available slot and updates slot assignments if the number of member is supported by the
* current pattern.
* @param member the member to add
* @return {@code false} if no more slots are available; {@code true} otherwise.
*/
public boolean addMember(FormationMember member){
// Check if the pattern supports one more slot
if(pattern.supportsSlots(slotAssignments.size + 1)){
// Add a new slot assignment
slotAssignments.add(new SlotAssignment(member, slotAssignments.size));
// Update the slot assignments and return success
updateSlotAssignments();
return true;
}
return false;
}
/**
* Removes a member from its slot and updates slot assignments.
* @param member the member to remove
*/
public void removeMember(FormationMember member){
// Find the member's slot
int slot = findMemberSlot(member);
// Make sure we've found a valid result
if(slot >= 0){
// Remove the slot
// slotAssignments.removeIndex(slot);
slotAssignmentStrategy.removeSlotAssignment(slotAssignments, slot);
// Update the assignments
updateSlotAssignments();
}
}
private int findMemberSlot(FormationMember member){
for(int i = 0; i < slotAssignments.size; i++){
if(slotAssignments.get(i).member == member) return i;
}
return -1;
}
// debug
public SlotAssignment getSlotAssignmentAt(int index){
return slotAssignments.get(index);
}
// debug
public int getSlotAssignmentCount(){
return slotAssignments.size;
}
/** Writes new slot locations to each member */
public void updateSlots(){
positionOffset.set(anchor);
float orientationOffset = anchor.z;
if(motionModerator != null){
positionOffset.sub(driftOffset);
orientationOffset -= driftOffset.z;
}
// Get the orientation of the anchor point as a matrix
orientationMatrix.idt().rotate(anchor.z);
// Go through each member in turn
for(int i = 0; i < slotAssignments.size; i++){
SlotAssignment slotAssignment = slotAssignments.get(i);
// Retrieve the location reference of the formation member to calculate the new value
Vec3 relativeLoc = slotAssignment.member.formationPos();
float z = relativeLoc.z;
// Ask for the location of the slot relative to the anchor point
pattern.calculateSlotLocation(relativeLoc, slotAssignment.slotNumber);
// Transform it by the anchor point's position and orientation
relativeLoc.mul(orientationMatrix);
// Add the anchor and drift components
relativeLoc.add(positionOffset.x, positionOffset.y, 0);
relativeLoc.z = z + orientationOffset;
}
// Possibly reset the anchor point if a moderator is set
if(motionModerator != null){
motionModerator.updateAnchorPoint(anchor);
}
}
}
@@ -1,16 +0,0 @@
package mindustry.ai.formations;
import arc.math.geom.*;
/**
* Game characters coordinated by a {@link Formation} must implement this interface. Any {@code FormationMember} has a target
* location which is the place where it should be in order to stay in formation. This target location is calculated by the
* formation itself.
* @author davebaol
*/
public interface FormationMember{
/** Returns the target location of this formation member. */
Vec3 formationPos();
float formationSize();
}
@@ -1,52 +0,0 @@
package mindustry.ai.formations;
import arc.math.geom.*;
import arc.struct.*;
/**
* A {@code FormationMotionModerator} moderates the movement of the formation based on the current positions of the members in its
* slots: in effect to keep the anchor point on a leash. If the members in the slots are having trouble reaching their targets,
* then the formation as a whole should be held back to give them a chance to catch up.
* @author davebaol
*/
public abstract class FormationMotionModerator{
private Vec3 tempLocation;
/**
* Update the anchor point to moderate formation motion. This method is called at each frame.
* @param anchor the anchor point
*/
public abstract void updateAnchorPoint(Vec3 anchor);
/**
* Calculates the drift offset when members are in the given set of slots for the specified pattern.
* @param centerOfMass the output location set to the calculated drift offset
* @param slotAssignments the set of slots
* @param pattern the pattern
* @return the given location for chaining.
*/
public Vec3 calculateDriftOffset(Vec3 centerOfMass, Seq<SlotAssignment> slotAssignments, FormationPattern pattern){
// Clear the center of mass
centerOfMass.x = centerOfMass.y = 0;
float centerOfMassOrientation = 0;
// Make sure tempLocation is instantiated
if(tempLocation == null) tempLocation = new Vec3();
// Go through each assignment and add its contribution to the center
float numberOfAssignments = slotAssignments.size;
for(int i = 0; i < numberOfAssignments; i++){
pattern.calculateSlotLocation(tempLocation, slotAssignments.get(i).slotNumber);
centerOfMass.add(tempLocation);
centerOfMassOrientation += tempLocation.z;
}
// Divide through to get the drift offset.
centerOfMass.scl(1f / numberOfAssignments);
centerOfMassOrientation /= numberOfAssignments;
centerOfMass.z = centerOfMassOrientation;
return centerOfMass;
}
}
@@ -1,29 +0,0 @@
package mindustry.ai.formations;
import arc.math.geom.*;
/**
* The {@code FormationPattern} interface represents the shape of a formation and generates the slot offsets, relative to its
* anchor point. Since formations can be scalable the pattern must be able to determine if a given number of slots is supported.
* <p>
* Each particular pattern (such as a V, wedge, circle) needs its own instance of a class that implements this
* {@code FormationPattern} interface.
* @author davebaol
*/
public abstract class FormationPattern{
public int slots;
/** Spacing between members. */
public float spacing = 20f;
/** Returns the location of the given slot index. */
public abstract Vec3 calculateSlotLocation(Vec3 out, int slot);
/**
* Returns true if the pattern can support the given number of slots
* @param slotCount the number of slots
* @return {@code true} if this pattern can support the given number of slots; {@code false} othervwise.
*/
public boolean supportsSlots(int slotCount){
return true;
}
}
@@ -1,34 +0,0 @@
package mindustry.ai.formations;
import arc.struct.*;
/**
* {@code FreeSlotAssignmentStrategy} is the simplest implementation of {@link SlotAssignmentStrategy}. It simply go through
* each assignment in the list and assign sequential slot numbers. The number of slots is just the length of the list.
* <p>
* Because each member can occupy any slot this implementation does not support roles.
* @author davebaol
*/
public class FreeSlotAssignmentStrategy implements SlotAssignmentStrategy{
@Override
public void updateSlotAssignments(Seq<SlotAssignment> assignments){
// A very simple assignment algorithm: we simply go through
// each assignment in the list and assign sequential slot numbers
for(int i = 0; i < assignments.size; i++){
assignments.get(i).slotNumber = i;
}
}
@Override
public int calculateNumberOfSlots(Seq<SlotAssignment> assignments){
return assignments.size;
}
@Override
public void removeSlotAssignment(Seq<SlotAssignment> assignments, int index){
assignments.remove(index);
}
}
@@ -1,29 +0,0 @@
package mindustry.ai.formations;
/**
* A {@code SlotAssignment} instance represents the assignment of a single {@link FormationMember} to its slot in the
* {@link Formation}.
* @author davebaol
*/
public class SlotAssignment{
public FormationMember member;
public int slotNumber;
/**
* Creates a {@code SlotAssignment} for the given {@code member}.
* @param member the member of this slot assignment
*/
public SlotAssignment(FormationMember member){
this(member, 0);
}
/**
* Creates a {@code SlotAssignment} for the given {@code member} and {@code slotNumber}.
* @param member the member of this slot assignment
*/
public SlotAssignment(FormationMember member, int slotNumber){
this.member = member;
this.slotNumber = slotNumber;
}
}
@@ -1,20 +0,0 @@
package mindustry.ai.formations;
import arc.struct.*;
/**
* This interface defines how each {@link FormationMember} is assigned to a slot in the {@link Formation}.
* @author davebaol
*/
public interface SlotAssignmentStrategy{
/** Updates the assignment of members to slots */
void updateSlotAssignments(Seq<SlotAssignment> assignments);
/** Calculates the number of slots from the assignment data. */
int calculateNumberOfSlots(Seq<SlotAssignment> assignments);
/** Removes the slot assignment at the specified index. */
void removeSlotAssignment(Seq<SlotAssignment> assignments, int index);
}
@@ -1,164 +0,0 @@
package mindustry.ai.formations;
import arc.struct.*;
import arc.util.*;
/**
* {@code SoftRoleSlotAssignmentStrategy} is a concrete implementation of {@link BoundedSlotAssignmentStrategy} that supports soft
* roles, i.e. roles that can be broken. Rather than a member having a list of roles it can fulfill, it has a set of values
* representing how difficult it would find it to fulfill every role. The value is known as the slot cost. To make a slot
* impossible for a member to fill, its slot cost should be infinite (you can even set a threshold to ignore all slots whose cost
* is too high; this will reduce computation time when several costs are exceeding). To make a slot ideal for a member, its slot
* cost should be zero. We can have different levels of unsuitable assignment for one member.
* <p>
* Slot costs do not necessarily have to depend only on the member and the slot roles. They can be generalized to include any
* difficulty a member might have in taking up a slot. If a formation is spread out, for example, a member may choose a slot that
* is close by over a more distant slot. Distance can be directly used as a slot cost.
* <p>
* <b>IMPORTANVec2 NOTES:</b>
* <ul>
* <li>In order for the algorithm to work properly the slot costs can not be negative.</li>
* <li>This algorithm is often not fast enough to be used regularly. However, slot assignment happens relatively seldom (when the
* player selects a new pattern, for example, or adds a member to the formation, or a member is removed from the formation).</li>
* </ul>
* @author davebaol
*/
public class SoftRoleSlotAssignmentStrategy extends BoundedSlotAssignmentStrategy{
protected SlotCostProvider slotCostProvider;
protected float costThreshold;
private BoolSeq filledSlots;
/**
* Creates a {@code SoftRoleSlotAssignmentStrategy} with the given slot cost provider and no cost threshold.
* @param slotCostProvider the slot cost provider
*/
public SoftRoleSlotAssignmentStrategy(SlotCostProvider slotCostProvider){
this(slotCostProvider, Float.POSITIVE_INFINITY);
}
/**
* Creates a {@code SoftRoleSlotAssignmentStrategy} with the given slot cost provider and cost threshold.
* @param slotCostProvider the slot cost provider
* @param costThreshold is a slot-cost limit, beyond which a slot is considered to be too expensive to consider occupying.
*/
public SoftRoleSlotAssignmentStrategy(SlotCostProvider slotCostProvider, float costThreshold){
this.slotCostProvider = slotCostProvider;
this.costThreshold = costThreshold;
this.filledSlots = new BoolSeq();
}
@Override
public void updateSlotAssignments(Seq<SlotAssignment> assignments){
// Holds a list of member and slot data for each member.
Seq<MemberAndSlots> memberData = new Seq<>();
// Compile the member data
int numberOfAssignments = assignments.size;
for(int i = 0; i < numberOfAssignments; i++){
SlotAssignment assignment = assignments.get(i);
// Create a new member datum, and fill it
MemberAndSlots datum = new MemberAndSlots(assignment.member);
// Add each valid slot to it
for(int j = 0; j < numberOfAssignments; j++){
// Get the cost of the slot
float cost = slotCostProvider.getCost(assignment.member, j);
// Make sure the slot is valid
if(cost >= costThreshold) continue;
SlotAssignment slot = assignments.get(j);
// Store the slot information
CostAndSlot slotDatum = new CostAndSlot(cost, slot.slotNumber);
datum.costAndSlots.add(slotDatum);
// Add it to the member's ease of assignment
datum.assignmentEase += 1f / (1f + cost);
}
// Add member datum
memberData.add(datum);
}
// Reset the array to keep track of which slots we have already filled.
if(numberOfAssignments > filledSlots.size) filledSlots.ensureCapacity(numberOfAssignments - filledSlots.size);
filledSlots.size = numberOfAssignments;
for(int i = 0; i < numberOfAssignments; i++)
filledSlots.set(i, false);
// Arrange members in order of ease of assignment, with the least easy first.
memberData.sort();
MEMBER_LOOP:
for(int i = 0; i < memberData.size; i++){
MemberAndSlots memberDatum = memberData.get(i);
// Choose the first slot in the list that is still empty (non-filled)
memberDatum.costAndSlots.sort();
int m = memberDatum.costAndSlots.size;
for(int j = 0; j < m; j++){
int slotNumber = memberDatum.costAndSlots.get(j).slotNumber;
// Check if this slot is valid
if(!filledSlots.get(slotNumber)){
// Fill this slot
SlotAssignment slot = assignments.get(slotNumber);
slot.member = memberDatum.member;
slot.slotNumber = slotNumber;
// Reserve the slot
filledSlots.set(slotNumber, true);
// Go to the next member
continue MEMBER_LOOP;
}
}
// If we reach here, it's because a member has no valid assignment.
//
// TODO
// Some sensible action should be taken, such as reporting to the player.
throw new ArcRuntimeException("SoftRoleSlotAssignmentStrategy cannot find valid slot assignment for member " + memberDatum.member);
}
}
static class CostAndSlot implements Comparable<CostAndSlot>{
float cost;
int slotNumber;
public CostAndSlot(float cost, int slotNumber){
this.cost = cost;
this.slotNumber = slotNumber;
}
@Override
public int compareTo(CostAndSlot other){
return Float.compare(cost, other.cost);
}
}
static class MemberAndSlots implements Comparable<MemberAndSlots>{
FormationMember member;
float assignmentEase;
Seq<CostAndSlot> costAndSlots;
public MemberAndSlots(FormationMember member){
this.member = member;
this.assignmentEase = 0f;
this.costAndSlots = new Seq<>();
}
@Override
public int compareTo(MemberAndSlots other){
return Float.compare(assignmentEase, other.assignmentEase);
}
}
public interface SlotCostProvider{
float getCost(FormationMember member, int slotNumber);
}
}
@@ -1,26 +0,0 @@
package mindustry.ai.formations.patterns;
import arc.math.*;
import arc.math.geom.*;
import mindustry.ai.formations.*;
public class CircleFormation extends FormationPattern{
/** Angle offset. */
public float angleOffset = 0;
@Override
public Vec3 calculateSlotLocation(Vec3 outLocation, int slotNumber){
if(slots > 1){
float angle = (360f * slotNumber) / slots;
float radius = spacing / (float)Math.sin(180f / slots * Mathf.degRad);
outLocation.set(Angles.trnsx(angle, radius), Angles.trnsy(angle, radius), angle);
}else{
outLocation.set(0, spacing * 1.1f, 360f * slotNumber);
}
outLocation.z += angleOffset;
return outLocation;
}
}
@@ -1,25 +0,0 @@
package mindustry.ai.formations.patterns;
import arc.math.*;
import arc.math.geom.*;
import mindustry.ai.formations.*;
public class SquareFormation extends FormationPattern{
@Override
public Vec3 calculateSlotLocation(Vec3 out, int slot){
//side of each square of formation
int side = Mathf.ceil(Mathf.sqrt(slots + 1));
int cx = slot % side, cy = slot / side;
//don't hog the middle spot
if(cx == side /2 && cy == side/2 && (side%2)==1){
slot = slots;
cx = slot % side;
cy = slot / side;
}
return out.set(cx - (side/2f - 0.5f), cy - (side/2f - 0.5f), 0).scl(spacing);
}
}
@@ -0,0 +1,25 @@
package mindustry.ai.types;
import arc.math.*;
import arc.math.geom.*;
import mindustry.entities.units.*;
public class AssemblerAI extends AIController{
public Vec2 targetPos = new Vec2();
public float targetAngle;
@Override
public void updateMovement(){
if(!targetPos.isZero()){
moveTo(targetPos, 1f, 3f);
}
if(unit.within(targetPos, 5f)){
unit.lookAt(targetAngle);
}
}
public boolean inPosition(){
return unit.within(targetPos, 10f) && Angles.within(unit.rotation, targetAngle, 15f);
}
}
+21
View File
@@ -0,0 +1,21 @@
package mindustry.ai.types;
import mindustry.ai.*;
import mindustry.entities.units.*;
//not meant to be used outside RTS-AI-controlled units
public class BoostAI extends AIController{
@Override
public void updateUnit(){
if(unit.controller() instanceof CommandAI ai){
ai.defaultBehavior();
unit.updateBoosting(true);
//auto land when near target
if(ai.attackTarget != null && unit.within(ai.attackTarget, unit.range())){
unit.command().command(UnitCommand.moveCommand);
}
}
}
}
+114 -29
View File
@@ -1,6 +1,5 @@
package mindustry.ai.types;
import arc.math.*;
import arc.struct.*;
import arc.util.*;
import mindustry.entities.*;
@@ -13,20 +12,55 @@ import mindustry.world.blocks.ConstructBlock.*;
import static mindustry.Vars.*;
public class BuilderAI extends AIController{
float buildRadius = 1500;
public static float buildRadius = 1500, retreatDst = 110f, retreatDelay = Time.toSeconds * 2f, defaultRebuildPeriod = 60f * 2f;
public @Nullable Unit assistFollowing;
public @Nullable Unit following;
public @Nullable Teamc enemy;
public @Nullable BlockPlan lastPlan;
public float fleeRange = 370f, rebuildPeriod = defaultRebuildPeriod;
public boolean alwaysFlee;
public boolean onlyAssist;
boolean found = false;
@Nullable Unit following;
float retreatTimer;
public BuilderAI(boolean alwaysFlee, float fleeRange){
this.alwaysFlee = alwaysFlee;
this.fleeRange = fleeRange;
}
public BuilderAI(){
}
@Override
public void init(){
//rebuild much faster with buildAI; there are usually few builder units so this is fine
if(rebuildPeriod == defaultRebuildPeriod && unit.team.rules().buildAi){
rebuildPeriod = 10f;
}
}
@Override
public void updateMovement(){
if(target != null && shouldShoot()){
unit.lookAt(target);
}else if(!unit.type.flying){
unit.lookAt(unit.prefRotation());
}
unit.updateBuilding = true;
if(assistFollowing != null && assistFollowing.activelyBuilding()){
following = assistFollowing;
}
boolean moving = false;
if(following != null){
retreatTimer = 0f;
//try to follow and mimic someone
//validate follower
@@ -39,39 +73,68 @@ public class BuilderAI extends AIController{
//set to follower's first build plan, whatever that is
unit.plans.clear();
unit.plans.addFirst(following.buildPlan());
lastPlan = null;
}else if(unit.buildPlan() == null || alwaysFlee){
//not following anyone or building
if(timer.get(timerTarget4, 40)){
enemy = target(unit.x, unit.y, fleeRange, true, true);
}
//fly away from enemy when not doing anything, but only after a delay
if((retreatTimer += Time.delta) >= retreatDelay || alwaysFlee){
if(enemy != null){
unit.clearBuilding();
var core = unit.closestCore();
if(core != null && !unit.within(core, retreatDst)){
moveTo(core, retreatDst);
moving = true;
}
}
}
}
if(unit.buildPlan() != null){
//approach request if building
if(!alwaysFlee) retreatTimer = 0f;
//approach plan if building
BuildPlan req = unit.buildPlan();
//clear break plan if another player is breaking something.
//clear break plan if another player is breaking something
if(!req.breaking && timer.get(timerTarget2, 40f)){
for(Player player : Groups.player){
if(player.isBuilder() && player.unit().activelyBuilding() && player.unit().buildPlan().samePos(req) && player.unit().buildPlan().breaking){
unit.plans.removeFirst();
//remove from list of plans
unit.team.data().plans.remove(p -> p.x == req.x && p.y == req.y);
return;
}
}
}
boolean valid =
(req.tile() != null && req.tile().build instanceof ConstructBuild cons && cons.cblock == req.block) ||
(req.breaking ?
Build.validBreak(unit.team(), req.x, req.y) :
Build.validPlace(req.block, unit.team(), req.x, req.y, req.rotation));
!(lastPlan != null && lastPlan.removed) &&
((req.tile() != null && req.tile().build instanceof ConstructBuild cons && cons.current == req.block) ||
(req.breaking ?
Build.validBreak(unit.team(), req.x, req.y) :
Build.validPlace(req.block, unit.team(), req.x, req.y, req.rotation)));
if(valid){
//move toward the request
moveTo(req.tile(), buildingRange - 20f);
//move toward the plan
moveTo(req.tile(), unit.type.buildRange - 20f, 20f);
moving = !unit.within(req.tile(), unit.type.buildRange - 10f);
}else{
//discard invalid request
//discard invalid plan
unit.plans.removeFirst();
lastPlan = null;
}
}else{
if(assistFollowing != null){
moveTo(assistFollowing, assistFollowing.type.hitSize + unit.type.hitSize/2f + 60f);
moving = !unit.within(assistFollowing, assistFollowing.type.hitSize + unit.type.hitSize/2f + 65f);
}
//follow someone and help them build
if(timer.get(timerTarget2, 60f)){
if(timer.get(timerTarget2, 20f)){
found = false;
Units.nearby(unit.team, unit.x, unit.y, buildRadius, u -> {
@@ -82,9 +145,9 @@ public class BuilderAI extends AIController{
Building build = world.build(plan.x, plan.y);
if(build instanceof ConstructBuild cons){
float dist = Math.min(cons.dst(unit) - buildingRange, 0);
float dist = Math.min(cons.dst(unit) - unit.type.buildRange, 0);
//make sure you can reach the request in time
//make sure you can reach the plan in time
if(dist / unit.speed() < cons.buildCost * 0.9f){
following = u;
found = true;
@@ -92,30 +155,52 @@ public class BuilderAI extends AIController{
}
}
});
if(onlyAssist){
float minDst = Float.MAX_VALUE;
Player closest = null;
for(var player : Groups.player){
if(!player.dead() && player.isBuilder() && player.team() == unit.team){
float dst = player.dst2(unit);
if(dst < minDst){
closest = player;
minDst = dst;
}
}
}
assistFollowing = closest == null ? null : closest.unit();
}
}
float rebuildTime = (unit.team.rules().ai ? Mathf.lerp(15f, 2f, unit.team.rules().aiTier) : 2f) * 60f;
//find new request
if(!unit.team.data().blocks.isEmpty() && following == null && timer.get(timerTarget3, rebuildTime)){
Queue<BlockPlan> blocks = unit.team.data().blocks;
//find new plan
if(!onlyAssist && !unit.team.data().plans.isEmpty() && following == null && timer.get(timerTarget3, rebuildPeriod)){
Queue<BlockPlan> blocks = unit.team.data().plans;
BlockPlan block = blocks.first();
//check if it's already been placed
if(world.tile(block.x, block.y) != null && world.tile(block.x, block.y).block().id == block.block){
if(world.tile(block.x, block.y) != null && world.tile(block.x, block.y).block() == block.block){
blocks.removeFirst();
}else if(Build.validPlace(content.block(block.block), unit.team(), block.x, block.y, block.rotation)){ //it's valid.
//add build request.
unit.addBuild(new BuildPlan(block.x, block.y, block.rotation, content.block(block.block), block.config));
//shift build plan to tail so next unit builds something else.
}else if(Build.validPlace(block.block, unit.team(), block.x, block.y, block.rotation) && (!alwaysFlee || !nearEnemy(block.x, block.y))){ //it's valid
lastPlan = block;
//add build plan
unit.addBuild(new BuildPlan(block.x, block.y, block.rotation, block.block, block.config));
//shift build plan to tail so next unit builds something else
blocks.addLast(blocks.removeFirst());
}else{
//shift head of queue to tail, try something else next time
blocks.removeFirst();
blocks.addLast(block);
blocks.addLast(blocks.removeFirst());
}
}
}
if(!unit.type.flying){
unit.updateBoosting(unit.type.boostWhenBuilding || moving || unit.floorOn().isDuct || unit.floorOn().damageTaken > 0f || unit.floorOn().isDeep());
}
}
protected boolean nearEnemy(int x, int y){
return Units.nearEnemy(unit.team, x * tilesize - fleeRange/2f, y * tilesize - fleeRange/2f, fleeRange, fleeRange);
}
@Override
@@ -125,11 +210,11 @@ public class BuilderAI extends AIController{
@Override
public boolean useFallback(){
return state.rules.waves && unit.team == state.rules.waveTeam && !unit.team.rules().ai;
return state.rules.waves && unit.team == state.rules.waveTeam && !unit.team.rules().rtsAi;
}
@Override
public boolean shouldShoot(){
return !unit.isBuilding();
return !unit.isBuilding() && unit.type.canAttack;
}
}
+193
View File
@@ -0,0 +1,193 @@
package mindustry.ai.types;
import arc.struct.*;
import arc.util.*;
import mindustry.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
import mindustry.type.*;
import mindustry.world.blocks.units.UnitCargoUnloadPoint.*;
import mindustry.world.meta.*;
import static mindustry.Vars.*;
public class CargoAI extends AIController{
static Seq<Item> orderedItems = new Seq<>();
static Seq<UnitCargoUnloadPointBuild> targets = new Seq<>();
public static float emptyWaitTime = 60f * 2f, dropSpacing = 60f * 1.5f;
public static float transferRange = 20f, moveRange = 6f, moveSmoothing = 20f;
public @Nullable UnitCargoUnloadPointBuild unloadTarget;
public @Nullable Item itemTarget;
public float noDestTimer = 0f;
public int targetIndex = 0;
@Override
public void updateMovement(){
if(!(unit instanceof BuildingTetherc tether) || tether.building() == null) return;
var build = tether.building();
if(build.items == null) return;
//empty, approach the loader, even if there's nothing to pick up (units hanging around doing nothing looks bad)
if(!unit.hasItem()){
moveTo(build, moveRange, moveSmoothing);
//check if ready to pick up
if(build.items.any() && unit.within(build, transferRange)){
if(retarget()){
findAnyTarget(build);
//target has been found, grab items and go
if(unloadTarget != null){
Call.takeItems(build, itemTarget, Math.min(unit.type.itemCapacity, build.items.get(itemTarget)), unit);
}
}
}
}else{ //the unit has an item, deposit it somewhere.
//there may be no current target, try to find one
if(unloadTarget == null){
if(retarget()){
findDropTarget(unit.item(), 0, null);
//if there is not even a single place to unload, dump items.
if(unloadTarget == null){
unit.clearItem();
}
}
}else{
//what if some prankster reconfigures or picks up the target while the unit is moving? we can't have that!
if(unloadTarget.item != itemTarget || unloadTarget.isPayload()){
unloadTarget = null;
return;
}
moveTo(unloadTarget, moveRange, moveSmoothing);
//deposit in bursts, unloading can take a while
if(unit.within(unloadTarget, transferRange) && timer.get(timerTarget2, dropSpacing)){
int max = unloadTarget.acceptStack(unit.item(), unit.stack.amount, unit);
//deposit items when it's possible
if(max > 0){
noDestTimer = 0f;
Call.transferItemTo(unit, unit.item(), max, unit.x, unit.y, unloadTarget);
//try the next target later
if(!unit.hasItem()){
targetIndex ++;
}
}else if((noDestTimer += dropSpacing) >= emptyWaitTime){
//oh no, it's out of space - wait for a while, and if nothing changes, try the next destination
//next targeting attempt will try the next destination point
targetIndex = findDropTarget(unit.item(), targetIndex, unloadTarget) + 1;
//nothing found at all, clear item
if(unloadTarget == null){
unit.clearItem();
}
}
}
}
}
}
/** find target for the unit's current item */
public int findDropTarget(Item item, int offset, UnitCargoUnloadPointBuild ignore){
unloadTarget = null;
itemTarget = item;
//autocast for convenience... I know all of these must be cargo unload points anyway
targets.selectFrom((Seq<UnitCargoUnloadPointBuild>)(Seq)Vars.indexer.getFlagged(unit.team, BlockFlag.unitCargoUnloadPoint), u -> u.item == item);
if(targets.isEmpty()) return 0;
UnitCargoUnloadPointBuild lastStale = null;
offset %= targets.size;
int i = 0;
for(var target : targets){
if(i >= offset && target != ignore){
if(target.stale){
lastStale = target;
}else{
unloadTarget = target;
targets.clear();
return i;
}
}
i ++;
}
//it's still possible that the ignored target may become available at some point, try that, so it doesn't waste items
if(ignore != null){
unloadTarget = ignore;
}else if(lastStale != null){ //a stale target is better than nothing
unloadTarget = lastStale;
}
targets.clear();
return -1;
}
public void findAnyTarget(Building build){
unloadTarget = null;
itemTarget = null;
//autocast for convenience... I know all of these must be cargo unload points anyway
var baseTargets = (Seq<UnitCargoUnloadPointBuild>)(Seq)Vars.indexer.getFlagged(unit.team, BlockFlag.unitCargoUnloadPoint);
if(baseTargets.isEmpty()) return;
orderedItems.size = 0;
for(Item item : content.items()){
if(build.items.get(item) > 0){
orderedItems.add(item);
}
}
//sort by most items in descending order, and try each one.
orderedItems.sort(i -> -build.items.get(i));
UnitCargoUnloadPointBuild lastStale = null;
outer:
for(Item item : orderedItems){
targets.selectFrom(baseTargets, u -> u.item == item);
if(targets.size > 0) itemTarget = item;
for(int i = 0; i < targets.size; i ++){
var target = targets.get((i + targetIndex) % targets.size);
lastStale = target;
if(!target.stale){
unloadTarget = target;
break outer;
}
}
}
//if the only thing that was found was a "stale" target, at least try that...
if(unloadTarget == null && lastStale != null){
unloadTarget = lastStale;
}
targets.clear();
}
//unused, might change later
void sortTargets(Seq<UnitCargoUnloadPointBuild> targets){
//find sort by "most desirable" first
targets.sort(Structs.comps(Structs.comparingInt(b -> b.items.total()), Structs.comparingFloat(b -> b.dst2(unit))));
}
}
+516
View File
@@ -0,0 +1,516 @@
package mindustry.ai.types;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.ai.*;
import mindustry.core.*;
import mindustry.entities.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
import mindustry.world.*;
import mindustry.world.blocks.payloads.*;
import mindustry.world.meta.*;
import static mindustry.Vars.*;
public class CommandAI extends AIController{
protected static final int maxCommandQueueSize = 50, avoidInterval = 10;
protected static final Vec2 vecOut = new Vec2(), vecMovePos = new Vec2();
protected static final boolean[] noFound = {false};
protected static final UnitPayload tmpPayload = new UnitPayload(null);
protected static final int transferStateNone = 0, transferStateLoad = 1, transferStateUnload = 2;
public Seq<Position> commandQueue = new Seq<>(5);
public @Nullable Vec2 targetPos;
public @Nullable Teamc attackTarget;
/** Group of units that were all commanded to reach the same point. */
public @Nullable UnitGroup group;
public int groupIndex = 0;
/** All encountered unreachable buildings of this AI. Why a sequence? Because contains() is very rarely called on it. */
public IntSeq unreachableBuildings = new IntSeq(8);
/** ID of unit read as target. This is set up after reading. Do not access! */
public int readAttackTarget = -1;
protected boolean stopAtTarget, stopWhenInRange;
protected Vec2 lastTargetPos;
protected boolean blockingUnit;
protected float timeSpentBlocked;
protected float payloadPickupCooldown;
protected int transferState = transferStateNone;
/** Stance, usually related to firing mode. */
public UnitStance stance = UnitStance.shoot;
/** Current command this unit is following. */
public UnitCommand command = UnitCommand.moveCommand;
/** Current controller instance based on command. */
protected @Nullable AIController commandController;
/** Last command type assigned. Used for detecting command changes. */
protected @Nullable UnitCommand lastCommand;
public UnitCommand currentCommand(){
return command == null ? UnitCommand.moveCommand : command;
}
/** Attempts to assign a command to this unit. If not supported by the unit type, does nothing. */
public void command(UnitCommand command){
if(unit.type.commands.contains(command)){
//clear old state.
unit.mineTile = null;
unit.clearBuilding();
this.command = command;
}
}
@Override
public boolean isLogicControllable(){
return !hasCommand();
}
public boolean isAttacking(){
return target != null && unit.within(target, unit.range() + 10f);
}
@Override
public void updateUnit(){
//this should not be possible
if(stance == UnitStance.stop) stance = UnitStance.shoot;
//pursue the target if relevant
if(stance == UnitStance.pursueTarget && target != null && attackTarget == null && targetPos == null){
commandTarget(target, false);
}
//pursue the target for patrol, keeping the current position
if(stance == UnitStance.patrol && target != null && attackTarget == null){
//commanding a target overwrites targetPos, so add it to the queue
if(targetPos != null){
commandQueue.add(targetPos.cpy());
}
commandTarget(target, false);
}
//remove invalid targets
if(commandQueue.any()){
commandQueue.removeAll(e -> e instanceof Healthc h && !h.isValid());
}
//assign defaults
if(command == null && unit.type.commands.size > 0){
command = unit.type.defaultCommand == null ? unit.type.commands.first() : unit.type.defaultCommand;
}
//update command controller based on index.
var curCommand = command;
if(lastCommand != curCommand){
lastCommand = curCommand;
commandController = (curCommand == null ? null : curCommand.controller.get(unit));
}
//use the command controller if it is provided, and bail out.
if(commandController != null){
if(commandController.unit() != unit) commandController.unit(unit);
commandController.updateUnit();
}else{
defaultBehavior();
//boosting control is not supported, so just don't.
unit.updateBoosting(false);
}
}
public void clearCommands(){
commandQueue.clear();
targetPos = null;
attackTarget = null;
}
void tryPickupUnit(Payloadc pay){
Unit target = Units.closest(unit.team, unit.x, unit.y, unit.type.hitSize * 2f, u -> u.isAI() && u != unit && u.isGrounded() && pay.canPickup(u) && u.within(unit, u.hitSize + unit.hitSize));
if(target != null){
Call.pickedUnitPayload(unit, target);
}
}
@Override
public Teamc findMainTarget(float x, float y, float range, boolean air, boolean ground){
if(!unit.type.autoFindTarget && !(targetPos == null || nearAttackTarget(unit.x, unit.y, unit.range()))){
return null;
}
return super.findMainTarget(x, y, range, air, ground);
}
public void defaultBehavior(){
if(!net.client() && unit instanceof Payloadc pay){
payloadPickupCooldown -= Time.delta;
//auto-drop everything
if(command == UnitCommand.unloadPayloadCommand && pay.hasPayload()){
Call.payloadDropped(unit, unit.x, unit.y);
}
//try to pick up what's under it
if(command == UnitCommand.loadUnitsCommand){
tryPickupUnit(pay);
}
//try to pick up a block
if(command == UnitCommand.loadBlocksCommand && (targetPos == null || unit.within(targetPos, 1f))){
Building build = world.buildWorld(unit.x, unit.y);
if(build != null && state.teams.canInteract(unit.team, build.team)){
//pick up block's payload
Payload current = build.getPayload();
if(current != null && pay.canPickupPayload(current)){
Call.pickedBuildPayload(unit, build, false);
//pick up whole building directly
}else if(build.block.buildVisibility != BuildVisibility.hidden && build.canPickup() && pay.canPickup(build)){
Call.pickedBuildPayload(unit, build, true);
}
}
}
}
if(!net.client() && command == UnitCommand.enterPayloadCommand && unit.buildOn() != null && (targetPos == null || (world.buildWorld(targetPos.x, targetPos.y) != null && world.buildWorld(targetPos.x, targetPos.y) == unit.buildOn()))){
var build = unit.buildOn();
tmpPayload.unit = unit;
if(build.team == unit.team && build.acceptPayload(build, tmpPayload)){
Call.unitEnteredPayload(unit, build);
return; //no use updating after this, the unit is gone!
}
}
updateVisuals();
updateTargeting();
if(attackTarget != null && invalid(attackTarget)){
attackTarget = null;
targetPos = null;
}
//move on to the next target
if(attackTarget == null && targetPos == null){
finishPath();
}
if(attackTarget != null){
if(targetPos == null){
targetPos = new Vec2();
lastTargetPos = targetPos;
}
targetPos.set(attackTarget);
if(unit.isGrounded() && attackTarget instanceof Building build && build.tile.solid() && unit.pathType() != Pathfinder.costLegs && stance != UnitStance.ram){
Tile best = build.findClosestEdge(unit, Tile::solid);
if(best != null){
targetPos.set(best);
}
}
}
boolean alwaysArrive = false;
float engageRange = unit.type.range - 10f;
boolean withinAttackRange = attackTarget != null && unit.within(attackTarget, engageRange) && stance != UnitStance.ram;
if(targetPos != null){
boolean move = true, isFinalPoint = commandQueue.size == 0;
vecOut.set(targetPos);
vecMovePos.set(targetPos);
//the enter payload command requires an exact position
if(group != null && group.valid && groupIndex < group.units.size && command != UnitCommand.enterPayloadCommand){
vecMovePos.add(group.positions[groupIndex * 2], group.positions[groupIndex * 2 + 1]);
}
Building targetBuild = world.buildWorld(targetPos.x, targetPos.y);
//TODO: should the unit stop when it finds a target?
if(
(stance == UnitStance.patrol && target != null && unit.within(target, unit.type.range - 2f) && !unit.type.circleTarget) ||
(command == UnitCommand.enterPayloadCommand && unit.within(targetPos, 4f) || (targetBuild != null && unit.within(targetBuild, targetBuild.block.size * tilesize/2f * 0.9f))) ||
(command == UnitCommand.loopPayloadCommand && unit.within(targetPos, 10f))
){
move = false;
}
if(unit.isGrounded() && stance != UnitStance.ram){
//TODO: blocking enable or disable?
if(timer.get(timerTarget3, avoidInterval)){
Vec2 dstPos = Tmp.v1.trns(unit.rotation, unit.hitSize/2f);
float max = unit.hitSize/2f;
float radius = Math.max(7f, max);
float margin = 4f;
blockingUnit = Units.nearbyCheck(unit.x + dstPos.x - radius/2f, unit.y + dstPos.y - radius/2f, radius, radius,
u -> u != unit && u.within(unit, u.hitSize/2f + unit.hitSize/2f + margin) && u.controller() instanceof CommandAI ai && ai.targetPos != null &&
//stop for other unit only if it's closer to the target
(ai.targetPos.equals(targetPos) && u.dst2(targetPos) < unit.dst2(targetPos)) &&
//don't stop if they're facing the same way
!Angles.within(unit.rotation, u.rotation, 15f) &&
//must be near an obstacle, stopping in open ground is pointless
ControlPathfinder.isNearObstacle(unit, unit.tileX(), unit.tileY(), u.tileX(), u.tileY()));
}
float maxBlockTime = 60f * 5f;
if(blockingUnit){
timeSpentBlocked += Time.delta;
if(timeSpentBlocked >= maxBlockTime*2f){
timeSpentBlocked = 0f;
}
}else{
timeSpentBlocked = 0f;
}
//if the unit is next to the target, stop asking the pathfinder how to get there, it's a waste of CPU
//TODO maybe stop moving too?
if(withinAttackRange){
move = true;
noFound[0] = false;
vecOut.set(vecMovePos);
}else{
move = controlPath.getPathPosition(unit, vecMovePos, targetPos, vecOut, noFound) && (!blockingUnit || timeSpentBlocked > maxBlockTime);
//TODO: what to do when there's a target and it can't be reached?
/*
if(noFound[0] && attackTarget != null && attackTarget.within(unit, unit.type.range * 2f)){
move = true;
vecOut.set(targetPos);
}*/
}
//rare case where unit must be perfectly aligned (happens with 1-tile gaps)
alwaysArrive = vecOut.epsilonEquals(unit.tileX() * tilesize, unit.tileY() * tilesize);
//we've reached the final point if the returned coordinate is equal to the supplied input
isFinalPoint &= vecMovePos.epsilonEquals(vecOut, 4.1f);
//if the path is invalid, stop trying and record the end as unreachable
if(unit.team.isAI() && (noFound[0] || unit.isPathImpassable(World.toTile(vecMovePos.x), World.toTile(vecMovePos.y)))){
if(attackTarget instanceof Building build){
unreachableBuildings.addUnique(build.pos());
}
attackTarget = null;
finishPath();
return;
}
}else{
vecOut.set(vecMovePos);
}
if(move){
if(unit.type.circleTarget && attackTarget != null){
target = attackTarget;
circleAttack(80f);
}else{
moveTo(vecOut,
withinAttackRange ? engageRange :
unit.isGrounded() ? 0f :
attackTarget != null && stance != UnitStance.ram ? engageRange : 0f,
unit.isFlying() ? 40f : 100f, false, null, isFinalPoint || alwaysArrive);
}
}
//if stopAtTarget is set, stop trying to move to the target once it is reached - used for defending
if(attackTarget != null && stopAtTarget && unit.within(attackTarget, engageRange - 1f)){
attackTarget = null;
}
if(unit.isFlying() && move && (attackTarget == null || !unit.within(attackTarget, unit.type.range))){
unit.lookAt(vecMovePos);
}else{
faceTarget();
}
//reached destination, end pathfinding
if(attackTarget == null && unit.within(vecMovePos, command.exactArrival && commandQueue.size == 0 ? 1f : Math.max(5f, unit.hitSize / 2f))){
finishPath();
}
if(stopWhenInRange && targetPos != null && unit.within(vecMovePos, engageRange * 0.9f)){
finishPath();
stopWhenInRange = false;
}
}else if(target != null){
faceTarget();
}
}
void finishPath(){
//the enter payload command never finishes until they are actually accepted
if(command == UnitCommand.enterPayloadCommand && commandQueue.size == 0 && targetPos != null && world.buildWorld(targetPos.x, targetPos.y) != null && world.buildWorld(targetPos.x, targetPos.y).block.acceptsUnitPayloads){
return;
}
if(!net.client() && command == UnitCommand.loopPayloadCommand && unit instanceof Payloadc pay){
if(transferState == transferStateNone){
transferState = pay.hasPayload() ? transferStateUnload : transferStateLoad;
}
if(payloadPickupCooldown > 0f) return;
if(transferState == transferStateUnload){
//drop until there's a failure
int prev = -1;
while(pay.hasPayload() && prev != pay.payloads().size){
prev = pay.payloads().size;
Call.payloadDropped(unit, unit.x, unit.y);
}
//wait for everything to unload before running code below
if(pay.hasPayload()){
return;
}
payloadPickupCooldown = 60f;
}else if(transferState == transferStateLoad){
//pick up units until there's a failure
int prev = -1;
while(prev != pay.payloads().size){
prev = pay.payloads().size;
tryPickupUnit(pay);
}
//wait to load things before running code below
if(!pay.hasPayload()){
return;
}
payloadPickupCooldown = 60f;
}
//it will never finish
if(commandQueue.size == 0){
return;
}
}
transferState = transferStateNone;
Vec2 prev = targetPos;
targetPos = null;
if(commandQueue.size > 0){
var next = commandQueue.remove(0);
if(next instanceof Teamc target){
commandTarget(target, this.stopAtTarget);
}else if(next instanceof Vec2 position){
commandPosition(position);
}
if(prev != null && (stance == UnitStance.patrol || command == UnitCommand.loopPayloadCommand)){
commandQueue.add(prev.cpy());
}
//make sure spot in formation is reachable
if(group != null){
group.updateRaycast(groupIndex, next instanceof Vec2 position ? position : Tmp.v3.set(next));
}
}else{
if(group != null){
group = null;
}
}
}
@Override
public void removed(Unit unit){
clearCommands();
}
public void commandQueue(Position location){
if(targetPos == null && attackTarget == null){
if(location instanceof Teamc t){
commandTarget(t, this.stopAtTarget);
}else if(location instanceof Vec2 position){
commandPosition(position);
}
}else if(commandQueue.size < maxCommandQueueSize && !commandQueue.contains(location)){
commandQueue.add(location);
}
}
@Override
public void afterRead(Unit unit){
if(readAttackTarget != -1){
attackTarget = Groups.unit.getByID(readAttackTarget);
readAttackTarget = -1;
}
}
@Override
public boolean shouldFire(){
return stance != UnitStance.holdFire;
}
@Override
public void hit(Bullet bullet){
if(unit.team.isAI() && bullet.owner instanceof Teamc teamc && teamc.team() != unit.team && attackTarget == null &&
//can only counter-attack every few seconds to prevent rapidly changing targets
!(teamc instanceof Unit u && !u.checkTarget(unit.type.targetAir, unit.type.targetGround)) && timer.get(timerTarget4, 60f * 10f)){
commandTarget(teamc, true);
}
}
@Override
public boolean keepState(){
return true;
}
@Override
public Teamc findTarget(float x, float y, float range, boolean air, boolean ground){
return !nearAttackTarget(x, y, range) ? super.findTarget(x, y, range, air, ground) : Units.isHittable(attackTarget, air, ground) ? attackTarget : null;
}
public boolean nearAttackTarget(float x, float y, float range){
return attackTarget != null && attackTarget.within(x, y, range + 3f + (attackTarget instanceof Sized s ? s.hitSize()/2f : 0f));
}
@Override
public boolean retarget(){
//retarget faster when there is an explicit target
return attackTarget != null ? timer.get(timerTarget, 10) : timer.get(timerTarget, 20);
}
public boolean hasCommand(){
return targetPos != null;
}
public void setupLastPos(){
lastTargetPos = targetPos;
}
@Override
public void commandPosition(Vec2 pos){
if(pos == null) return;
commandPosition(pos, false);
if(commandController != null){
commandController.commandPosition(pos);
}
}
public void commandPosition(Vec2 pos, boolean stopWhenInRange){
if(pos == null) return;
//this is an allocation, but it's relatively rarely called anyway, and outside mutations must be prevented
targetPos = lastTargetPos = pos.cpy();
attackTarget = null;
this.stopWhenInRange = stopWhenInRange;
}
@Override
public void commandTarget(Teamc moveTo){
commandTarget(moveTo, false);
if(commandController != null){
commandController.commandTarget(moveTo);
}
}
public void commandTarget(Teamc moveTo, boolean stopAtTarget){
attackTarget = moveTo;
this.stopAtTarget = stopAtTarget;
}
}
@@ -0,0 +1,46 @@
package mindustry.ai.types;
import arc.math.*;
import mindustry.entities.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
import static mindustry.Vars.*;
public class DefenderAI extends AIController{
@Override
public void updateMovement(){
unloadPayloads();
if(target != null){
moveTo(target, (target instanceof Sized s ? s.hitSize()/2f * 1.1f : 0f) + unit.hitSize/2f + 15f, 50f);
unit.lookAt(target);
}
}
@Override
public void updateTargeting(){
if(retarget()) target = findTarget(unit.x, unit.y, unit.range(), true, true);
}
@Override
public Teamc findTarget(float x, float y, float range, boolean air, boolean ground){
//Sort by max health and closer target.
var result = Units.closest(unit.team, x, y, Math.max(range, 400f), u -> !u.dead() && u.type != unit.type && u.targetable(unit.team) && u.type.playerControllable,
(u, tx, ty) -> -u.maxHealth + Mathf.dst2(u.x, u.y, tx, ty) / 6400f);
if(result != null) return result;
//return core if found
var core = unit.closestCore();
if(core != null) return core;
//for enemies, target the enemy core.
if(state.rules.waves && unit.team == state.rules.waveTeam){
return unit.closestEnemyCore();
}
return null;
}
}
+68 -31
View File
@@ -2,61 +2,98 @@ package mindustry.ai.types;
import arc.math.*;
import mindustry.entities.units.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.world.meta.*;
import static mindustry.Vars.*;
import static mindustry.world.meta.BlockFlag.*;
public class FlyingAI extends AIController{
final static Rand rand = new Rand();
final static BlockFlag[] randomTargets = {core, storage, generator, launchPad, factory, repair, battery, reactor, drill};
@Override
public void updateMovement(){
if(target != null && unit.hasWeapons() && command() == UnitCommand.attack){
if(!unit.type.circleTarget){
moveTo(target, unit.range() * 0.8f);
unit.lookAt(target);
unloadPayloads();
if(target != null && unit.hasWeapons()){
if(unit.type.circleTarget){
circleAttack(120f);
}else{
attack(120f);
moveTo(target, unit.type.range * 0.8f);
unit.lookAt(target);
}
}
if(target == null && command() == UnitCommand.attack && state.rules.waves && unit.team == state.rules.defaultTeam){
moveTo(getClosestSpawner(), state.rules.dropZoneRadius + 120f);
}
if(command() == UnitCommand.rally){
moveTo(targetFlag(unit.x, unit.y, BlockFlag.rally, false), 60f);
if(target == null && state.rules.waves && unit.team == state.rules.defaultTeam){
moveTo(getClosestSpawner(), state.rules.dropZoneRadius + 130f);
}
}
@Override
protected Teamc findTarget(float x, float y, float range, boolean air, boolean ground){
Teamc result = target(x, y, range, air, ground);
if(result != null) return result;
public Teamc targetFlag(float x, float y, BlockFlag flag, boolean enemy){
if(state.rules.randomWaveAI){
if(unit.team == Team.derelict) return null;
var list = enemy ? indexer.getEnemy(unit.team, flag) : indexer.getFlagged(unit.team, flag);
if(list.isEmpty()) return null;
if(ground) result = targetFlag(x, y, BlockFlag.generator, true);
if(result != null) return result;
if(ground) result = targetFlag(x, y, BlockFlag.core, true);
if(result != null) return result;
return null;
Building closest = null;
float cdist = 0f;
for(Building t : list){
if((t.items != null && t.items.any()) || t.status() != BlockStatus.noInput){
float dst = t.dst2(x, y);
if(closest == null || dst < cdist){
closest = t;
cdist = dst;
}
}
}
return closest;
}else{
return super.targetFlag(x, y, flag, enemy);
}
}
protected void attack(float circleLength){
vec.set(target).sub(unit);
@Override
public Teamc findTarget(float x, float y, float range, boolean air, boolean ground){
var result = findMainTarget(x, y, range, air, ground);
float ang = unit.angleTo(target);
float diff = Angles.angleDist(ang, unit.rotation());
//if the main target is in range, use it, otherwise target whatever is closest
return checkTarget(result, x, y, range) ? target(x, y, range, air, ground) : result;
}
if(diff > 100f && vec.len() < circleLength){
vec.setAngle(unit.vel().angle());
}else{
vec.setAngle(Mathf.slerpDelta(unit.vel().angle(), vec.angle(), 0.6f));
@Override
public Teamc findMainTarget(float x, float y, float range, boolean air, boolean ground){
var core = targetFlag(x, y, BlockFlag.core, true);
if(core != null && Mathf.within(x, y, core.getX(), core.getY(), range)){
return core;
}
vec.setLength(unit.speed());
if(state.rules.randomWaveAI){
//when there are no waves, it's just random based on the unit
rand.setSeed(unit.type.id + (state.rules.waves ? state.wave : unit.id));
//try a few random flags first
for(int attempt = 0; attempt < 5; attempt++){
Teamc result = targetFlag(x, y, randomTargets[rand.random(randomTargets.length - 1)], true);
if(result != null) return result;
}
//try the closest target
Teamc result = target(x, y, range, air, ground);
if(result != null) return result;
}else{
for(var flag : unit.type.targetFlags){
if(flag == null){
Teamc result = target(x, y, range, air, ground);
if(result != null) return result;
}else if(ground){
Teamc result = targetFlag(x, y, flag, true);
if(result != null) return result;
}
}
}
unit.moveAt(vec);
return core;
}
}
@@ -0,0 +1,61 @@
package mindustry.ai.types;
import arc.math.*;
import mindustry.*;
import mindustry.entities.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
//TODO generally strange behavior
/** AI/wave team only! This is used for wave support flyers. */
public class FlyingFollowAI extends FlyingAI{
public Teamc following;
@Override
public void updateMovement(){
unloadPayloads();
if(following != null){
moveTo(following, (following instanceof Sized s ? s.hitSize()/2f * 1.1f : 0f) + unit.hitSize/2f + 15f, 50f);
}else if(target != null && unit.hasWeapons()){
moveTo(target, 80f);
}
if(shouldFaceTarget()){
unit.lookAt(target);
}else if(following != null){
unit.lookAt(following);
}
if(timer.get(timerTarget3, 30f)){
following = Units.closest(unit.team, unit.x, unit.y, Math.max(unit.type.range, 400f), u -> !u.dead() && u.type != unit.type, (u, tx, ty) -> -u.maxHealth + Mathf.dst2(u.x, u.y, tx, ty) / 6400f);
}
}
public boolean shouldFaceTarget(){
return target != null && (following == null || unit.within(target, unit.range()));
}
@Override
public void updateVisuals(){
if(unit.isFlying()){
if(unit.type.wobble) unit.wobble();
if(!shouldFaceTarget()){
unit.lookAt(unit.prefRotation());
}
}
}
@Override
public AIController fallback(){
return new FlyingAI();
}
@Override
public boolean useFallback(){
//only AI teams use this controller
return Vars.state.rules.pvp || Vars.state.rules.waveTeam != unit.team;
}
}
@@ -1,100 +0,0 @@
package mindustry.ai.types;
import arc.math.*;
import arc.math.geom.*;
import arc.util.*;
import mindustry.ai.formations.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
import mindustry.world.blocks.storage.CoreBlock.*;
public class FormationAI extends AIController implements FormationMember{
public Unit leader;
private Vec3 target = new Vec3();
private @Nullable Formation formation;
public FormationAI(Unit leader, Formation formation){
this.leader = leader;
this.formation = formation;
}
@Override
public void init(){
target.set(unit.x, unit.y, 0);
}
@Override
public void updateUnit(){
if(leader == null || leader.dead){
unit.resetController();
return;
}
if(unit.type.canBoost){
unit.elevation = Mathf.approachDelta(unit.elevation, unit.onSolid() ? 1f : leader.type.canBoost ? leader.elevation : 0f, 0.08f);
}
unit.controlWeapons(true, leader.isShooting);
unit.aim(leader.aimX(), leader.aimY());
if(unit.type.rotateShooting){
unit.lookAt(leader.aimX(), leader.aimY());
}else if(unit.moving()){
unit.lookAt(unit.vel.angle());
}
Vec2 realtarget = vec.set(target).add(leader.vel.x, leader.vel.y);
float speed = unit.realSpeed() * unit.floorSpeedMultiplier() * Time.delta;
unit.approach(Mathf.arrive(unit.x, unit.y, realtarget.x, realtarget.y, unit.vel, speed, 0f, speed, 1f).scl(1f / Time.delta));
if(unit.canMine() && leader.canMine()){
if(leader.mineTile != null && unit.validMine(leader.mineTile)){
unit.mineTile(leader.mineTile);
CoreBuild core = unit.team.core();
if(core != null && leader.mineTile.drop() != null && unit.within(core, unit.type.range) && !unit.acceptsItem(leader.mineTile.drop())){
if(core.acceptStack(unit.stack.item, unit.stack.amount, unit) > 0){
Call.transferItemTo(unit, unit.stack.item, unit.stack.amount, unit.x, unit.y, core);
unit.clearItem();
}
}
}else{
unit.mineTile(null);
}
}
if(unit.canBuild() && leader.canBuild() && leader.activelyBuilding()){
unit.clearBuilding();
unit.addBuild(leader.buildPlan());
}
}
@Override
public void removed(Unit unit){
if(formation != null){
formation.removeMember(this);
unit.resetController();
}
}
@Override
public float formationSize(){
return unit.hitSize * 1.1f;
}
@Override
public boolean isBeingControlled(Unit player){
return leader == player;
}
@Override
public Vec3 formationPos(){
return target;
}
}
+16 -25
View File
@@ -2,13 +2,9 @@ package mindustry.ai.types;
import arc.math.*;
import mindustry.ai.*;
import mindustry.entities.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
import mindustry.world.*;
import mindustry.world.meta.*;
import java.util.*;
import static mindustry.Vars.*;
@@ -19,41 +15,36 @@ public class GroundAI extends AIController{
Building core = unit.closestEnemyCore();
if(core != null && unit.within(core, unit.range() / 1.1f + core.block.size * tilesize / 2f)){
if(core != null && unit.within(core, unit.range() / 1.3f + core.block.size * tilesize / 2f)){
target = core;
Arrays.fill(targets, core);
for(var mount : unit.mounts){
if(mount.weapon.controllable && mount.weapon.bullet.collidesGround){
mount.target = core;
}
}
}
if((core == null || !unit.within(core, unit.range() * 0.5f)) && command() == UnitCommand.attack){
if((core == null || !unit.within(core, unit.type.range * 0.5f))){
boolean move = true;
if(state.rules.waves && unit.team == state.rules.defaultTeam){
Tile spawner = getClosestSpawner();
if(spawner != null && unit.within(spawner, state.rules.dropZoneRadius + 120f)) move = false;
if(spawner == null && core == null) move = false;
}
//no reason to move if there's nothing there
if(core == null && (!state.rules.waves || getClosestSpawner() == null)){
move = false;
}
if(move) pathfind(Pathfinder.fieldCore);
}
if(command() == UnitCommand.rally){
Teamc target = targetFlag(unit.x, unit.y, BlockFlag.rally, false);
if(target != null && !unit.within(target, 70f)){
pathfind(Pathfinder.fieldRally);
}
}
if(unit.type.canBoost && !unit.onSolid()){
unit.elevation = Mathf.approachDelta(unit.elevation, 0f, 0.08f);
}
if(!Units.invalidateTarget(target, unit, unit.range()) && unit.type.rotateShooting){
if(unit.type.hasWeapons()){
unit.lookAt(Predict.intercept(unit, target, unit.type.weapons.first().bullet.speed));
}
}else if(unit.moving()){
unit.lookAt(unit.vel().angle());
if(unit.type.canBoost && unit.elevation > 0.001f && !unit.onSolid()){
unit.elevation = Mathf.approachDelta(unit.elevation, 0f, unit.type.riseSpeed);
}
faceTarget();
}
}
+63
View File
@@ -0,0 +1,63 @@
package mindustry.ai.types;
import arc.math.*;
import arc.math.geom.*;
import mindustry.ai.*;
import mindustry.core.*;
import mindustry.entities.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
import mindustry.world.*;
import static mindustry.Vars.*;
public class HugAI extends AIController{
@Override
public void updateMovement(){
Building core = unit.closestEnemyCore();
if(core != null && unit.within(core, unit.range() / 1.1f + core.block.size * tilesize / 2f)){
target = core;
for(var mount : unit.mounts){
if(mount.weapon.controllable && mount.weapon.bullet.collidesGround){
mount.target = core;
}
}
}
boolean move = true;
if(state.rules.waves && unit.team == state.rules.defaultTeam){
Tile spawner = getClosestSpawner();
if(spawner != null && unit.within(spawner, state.rules.dropZoneRadius + 120f)) move = false;
}
//raycast for target
if(target != null && unit.within(target, unit.type.range) && !World.raycast(unit.tileX(), unit.tileY(), target.tileX(), target.tileY(), (x, y) -> {
for(Point2 p : Geometry.d4c){
if(!unit.canPass(x + p.x, y + p.y)){
return true;
}
}
return false;
})){
if(unit.within(target, (unit.hitSize + (target instanceof Sized s ? s.hitSize() : 1f)) * 0.5f)){
//circle target
unit.movePref(vec.set(target).sub(unit).rotate(90f).setLength(unit.speed()));
}else{
//move toward target in a straight line
unit.movePref(vec.set(target).sub(unit).limit(unit.speed()));
}
}else if(move){
pathfind(Pathfinder.fieldCore);
}
if(unit.type.canBoost && unit.elevation > 0.001f && !unit.onSolid()){
unit.elevation = Mathf.approachDelta(unit.elevation, 0f, unit.type.riseSpeed);
}
faceTarget();
}
}
+41 -42
View File
@@ -1,7 +1,6 @@
package mindustry.ai.types;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.ai.*;
@@ -9,19 +8,18 @@ import mindustry.entities.units.*;
import mindustry.gen.*;
import mindustry.logic.*;
import mindustry.world.*;
import mindustry.world.meta.*;
import static mindustry.Vars.*;
public class LogicAI extends AIController{
/** Minimum delay between item transfers. */
public static final float transferDelay = 60f * 2f;
public static final float transferDelay = 60f * 1.5f;
/** Time after which the unit resets its controlled and reverts to a normal unit. */
public static final float logicControlTimeout = 10f * 60f;
public static final float logicControlTimeout = 60f * 10f;
public LUnitControl control = LUnitControl.stop;
public LUnitControl control = LUnitControl.idle;
public float moveX, moveY, moveRad;
public float itemTimer, payTimer, controlTimer = logicControlTimeout, targetTimer;
public float controlTimer = logicControlTimeout, targetTimer;
@Nullable
public Building controller;
public BuildPlan plan = new BuildPlan();
@@ -43,11 +41,14 @@ public class LogicAI extends AIController{
private ObjectSet<Object> radars = new ObjectSet<>();
// LogicAI state should not be reset after reading.
@Override
protected void updateMovement(){
if(itemTimer >= 0) itemTimer -= Time.delta;
if(payTimer >= 0) payTimer -= Time.delta;
public boolean keepState(){
return true;
}
@Override
public void updateMovement(){
if(targetTimer > 0f){
targetTimer -= Time.delta;
}else{
@@ -68,27 +69,38 @@ public class LogicAI extends AIController{
moveTo(Tmp.v1.set(moveX, moveY), 1f, 30f);
}
case approach -> {
moveTo(Tmp.v1.set(moveX, moveY), moveRad - 7f, 7);
moveTo(Tmp.v1.set(moveX, moveY), moveRad - 7f, 7, true, null);
}
case pathfind -> {
if(unit.isFlying()){
moveTo(Tmp.v1.set(moveX, moveY), 1f, 30f);
}else{
if(controlPath.getPathPosition(unit, Tmp.v2.set(moveX, moveY), Tmp.v2, Tmp.v1, null)){
moveTo(Tmp.v1, 1f, Tmp.v2.epsilonEquals(Tmp.v1, 4.1f) ? 30f : 0f);
}
}
}
case autoPathfind -> {
Building core = unit.closestEnemyCore();
if((core == null || !unit.within(core, unit.range() * 0.5f)) && command() == UnitCommand.attack){
if((core == null || !unit.within(core, unit.range() * 0.5f))){
boolean move = true;
Tile spawner = null;
if(state.rules.waves && unit.team == state.rules.defaultTeam){
Tile spawner = getClosestSpawner();
spawner = getClosestSpawner();
if(spawner != null && unit.within(spawner, state.rules.dropZoneRadius + 120f)) move = false;
}
if(move) pathfind(Pathfinder.fieldCore);
}
if(command() == UnitCommand.rally){
Teamc target = targetFlag(unit.x, unit.y, BlockFlag.rally, false);
if(target != null && !unit.within(target, 70f)){
pathfind(Pathfinder.fieldRally);
if(move){
if(unit.isFlying()){
var target = core == null ? spawner : core;
if(target != null){
moveTo(target, unit.range() * 0.5f);
}
}else{
pathfind(Pathfinder.fieldCore);
}
}
}
}
@@ -98,13 +110,13 @@ public class LogicAI extends AIController{
}
if(unit.type.canBoost && !unit.type.flying){
unit.elevation = Mathf.approachDelta(unit.elevation, Mathf.num(boost || unit.onSolid()), 0.08f);
unit.elevation = Mathf.approachDelta(unit.elevation, Mathf.num(boost || unit.onSolid() || (unit.isFlying() && !unit.canLand())), unit.type.riseSpeed);
}
//look where moving if there's nothing to aim at
if(!shoot){
if(!shoot || !unit.type.omniMovement){
unit.lookAt(unit.prefRotation());
}else if(unit.hasWeapons() && unit.mounts.length > 0){ //if there is, look at the object
}else if(unit.hasWeapons() && unit.mounts.length > 0 && !unit.mounts[0].weapon.ignoreRotation){ //if there is, look at the object
unit.lookAt(unit.mounts[0].aimX, unit.mounts[0].aimY);
}
}
@@ -114,42 +126,29 @@ public class LogicAI extends AIController{
}
@Override
protected void moveTo(Position target, float circleLength, float smooth){
if(target == null) return;
vec.set(target).sub(unit);
float length = circleLength <= 0.001f ? 1f : Mathf.clamp((unit.dst(target) - circleLength) / smooth, -1f, 1f);
vec.setLength(unit.realSpeed() * length);
if(length < -0.5f){
vec.rotate(180f);
}else if(length < 0){
vec.setZero();
}
unit.approach(vec);
public boolean checkTarget(Teamc target, float x, float y, float range){
return false;
}
//always retarget
@Override
protected boolean retarget(){
public boolean retarget(){
return true;
}
@Override
protected boolean invalid(Teamc target){
public boolean invalid(Teamc target){
return false;
}
@Override
protected boolean shouldShoot(){
public boolean shouldShoot(){
return shoot && !(unit.type.canBoost && boost);
}
//always aim for the main target
@Override
protected Teamc target(float x, float y, float range, boolean air, boolean ground){
public Teamc target(float x, float y, float range, boolean air, boolean ground){
return switch(aimControl){
case target -> posTarget;
case targetp -> mainTarget;
+11 -15
View File
@@ -9,29 +9,29 @@ import mindustry.world.*;
import static mindustry.Vars.*;
public class MinerAI extends AIController{
boolean mining = true;
Item targetItem;
Tile ore;
public boolean mining = true;
public Item targetItem;
public Tile ore;
@Override
protected void updateMovement(){
public void updateMovement(){
Building core = unit.closestCore();
if(!(unit.canMine()) || core == null) return;
if(unit.mineTile != null && !unit.mineTile.within(unit, unit.type.range)){
if(!unit.validMine(unit.mineTile)){
unit.mineTile(null);
}
if(mining){
if(timer.get(timerTarget2, 60 * 4) || targetItem == null){
targetItem = unit.team.data().mineItems.min(i -> indexer.hasOre(i) && unit.canMine(i), i -> core.items.get(i));
targetItem = unit.type.mineItems.min(i -> indexer.hasOre(i) && unit.canMine(i), i -> core.items.get(i));
}
//core full of the target item, do nothing
if(targetItem != null && core.acceptStack(targetItem, 1, unit) == 0){
unit.clearItem();
unit.mineTile(null);
unit.mineTile = null;
return;
}
@@ -39,14 +39,14 @@ public class MinerAI extends AIController{
if(unit.stack.amount >= unit.type.itemCapacity || (targetItem != null && !unit.acceptsItem(targetItem))){
mining = false;
}else{
if(timer.get(timerTarget, 60) && targetItem != null){
if(timer.get(timerTarget3, 60) && targetItem != null){
ore = indexer.findClosestOre(unit, targetItem);
}
if(ore != null){
moveTo(ore, unit.type.range / 2f, 20f);
moveTo(ore, unit.type.mineRange / 2f, 20f);
if(unit.within(ore, unit.type.range)){
if(ore.block() == Blocks.air && unit.within(ore, unit.type.mineRange)){
unit.mineTile = ore;
}
@@ -75,8 +75,4 @@ public class MinerAI extends AIController{
circle(core, unit.type.range / 1.8f);
}
}
@Override
protected void updateTargeting(){
}
}
}
@@ -0,0 +1,49 @@
package mindustry.ai.types;
import arc.math.*;
import arc.util.*;
import mindustry.*;
import mindustry.entities.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
public class MissileAI extends AIController{
public @Nullable Unit shooter;
@Override
protected void resetTimers(){
timer.reset(timerTarget, 5f);
}
@Override
public void updateMovement(){
unloadPayloads();
float time = unit instanceof TimedKillc t ? t.time() : 1000000f;
if(time >= unit.type.homingDelay && shooter != null && !shooter.dead()){
unit.lookAt(shooter.aimX, shooter.aimY);
}
//move forward forever
unit.moveAt(vec.trns(unit.rotation, unit.type.missileAccelTime <= 0f ? unit.speed() : Mathf.pow(Math.min(time / unit.type.missileAccelTime, 1f), 2f) * unit.speed()));
var build = unit.buildOn();
//kill instantly on enemy building contact
if(build != null && build.team != unit.team && (build == target || !build.block.underBullets)){
unit.kill();
}
}
@Override
public Teamc target(float x, float y, float range, boolean air, boolean ground){
return Units.closestTarget(unit.team, x, y, range, u -> u.checkTarget(air, ground) && !u.isMissile(), t -> ground && (!t.block.underBullets || (shooter != null && t == Vars.world.buildWorld(shooter.aimX, shooter.aimY))));
}
@Override
public boolean retarget(){
//more frequent retarget due to high speed. TODO won't this lag?
return timer.get(timerTarget, 4f);
}
}
+40 -10
View File
@@ -1,14 +1,20 @@
package mindustry.ai.types;
import arc.util.*;
import mindustry.entities.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
import mindustry.world.blocks.ConstructBlock.*;
public class RepairAI extends AIController{
public static float retreatDst = 160f, fleeRange = 310f, retreatDelay = Time.toSeconds * 3f;
@Nullable Teamc avoid;
float retreatTimer;
Building damagedTarget;
@Override
protected void updateMovement(){
public void updateMovement(){
if(target instanceof Building){
boolean shoot = false;
@@ -22,25 +28,49 @@ public class RepairAI extends AIController{
unit.controlWeapons(false);
}
if(target != null){
if(!target.within(unit, unit.type.range * 0.65f) && target instanceof Building b && b.team == unit.team){
if(target != null && target instanceof Building b && b.team == unit.team){
if(unit.type.circleTarget){
circleAttack(120f);
}else if(!target.within(unit, unit.type.range * 0.65f)){
moveTo(target, unit.type.range * 0.65f);
}
unit.lookAt(target);
if(!unit.type.circleTarget){
unit.lookAt(target);
}
}
//not repairing
if(!(target instanceof Building)){
if(timer.get(timerTarget4, 40)){
avoid = target(unit.x, unit.y, fleeRange, true, true);
}
if((retreatTimer += Time.delta) >= retreatDelay){
//fly away from enemy when not doing anything
if(avoid != null){
var core = unit.closestCore();
if(core != null && !unit.within(core, retreatDst)){
moveTo(core, retreatDst);
}
}
}
}else{
retreatTimer = 0f;
}
}
@Override
protected void updateTargeting(){
Building target = Units.findDamagedTile(unit.team, unit.x, unit.y);
public void updateTargeting(){
if(timer.get(timerTarget, 15)){
damagedTarget = Units.findDamagedTile(unit.team, unit.x, unit.y);
if(damagedTarget instanceof ConstructBuild) damagedTarget = null;
}
if(target instanceof ConstructBuild) target = null;
if(target == null){
if(damagedTarget == null){
super.updateTargeting();
}else{
this.target = target;
this.target = damagedTarget;
}
}
}
+35 -25
View File
@@ -1,21 +1,24 @@
package mindustry.ai.types;
import arc.math.geom.*;
import mindustry.*;
import mindustry.ai.*;
import mindustry.core.*;
import mindustry.entities.*;
import mindustry.entities.units.*;
import mindustry.gen.*;
import mindustry.world.*;
import mindustry.world.blocks.distribution.*;
import mindustry.world.blocks.liquid.*;
import mindustry.world.blocks.storage.*;
import mindustry.world.meta.*;
import static mindustry.Vars.*;
public class SuicideAI extends GroundAI{
static boolean blockedByBlock;
@Override
public void updateUnit(){
if(Units.invalidateTarget(target, unit.team, unit.x, unit.y, Float.MAX_VALUE)){
target = null;
}
@@ -28,17 +31,17 @@ public class SuicideAI extends GroundAI{
boolean rotate = false, shoot = false, moveToTarget = false;
if(target == null){
target = core;
}
if(!Units.invalidateTarget(target, unit, unit.range()) && unit.hasWeapons()){
rotate = true;
shoot = unit.within(target, unit.type.weapons.first().bullet.range() +
shoot = unit.within(target, unit.type.weapons.first().bullet.range +
(target instanceof Building b ? b.block.size * Vars.tilesize / 2f : ((Hitboxc)target).hitSize() / 2f));
if(unit.type.hasWeapons()){
unit.aimLook(Predict.intercept(unit, target, unit.type.weapons.first().bullet.speed));
}
//do not move toward walls or transport blocks
if(!(target instanceof Building build && (
if(!(target instanceof Building build && !(build.block instanceof CoreBlock) && (
build.block.group == BlockGroup.walls ||
build.block.group == BlockGroup.liquids ||
build.block.group == BlockGroup.transportation
@@ -46,15 +49,18 @@ public class SuicideAI extends GroundAI{
blockedByBlock = false;
//raycast for target
boolean blocked = Vars.world.raycast(unit.tileX(), unit.tileY(), target.tileX(), target.tileY(), (x, y) -> {
Tile tile = Vars.world.tile(x, y);
if(tile != null && tile.build == target) return false;
if(tile != null && tile.build != null && tile.build.team != unit.team()){
blockedByBlock = true;
return true;
}else{
return tile == null || tile.solid();
boolean blocked = World.raycast(unit.tileX(), unit.tileY(), target.tileX(), target.tileY(), (x, y) -> {
for(Point2 p : Geometry.d4c){
Tile tile = Vars.world.tile(x + p.x, y + p.y);
if(tile != null && tile.build == target) return false;
if(tile != null && tile.build != null && tile.build.team != unit.team()){
blockedByBlock = true;
return true;
}else{
return tile == null || tile.solid();
}
}
return false;
});
//shoot when there's an enemy block in the way
@@ -65,30 +71,34 @@ public class SuicideAI extends GroundAI{
if(!blocked){
moveToTarget = true;
//move towards target directly
unit.moveAt(vec.set(target).sub(unit).limit(unit.speed()));
unit.movePref(vec.set(target).sub(unit).limit(unit.speed()));
}
}
}
if(!moveToTarget){
if(command() == UnitCommand.rally){
Teamc target = targetFlag(unit.x, unit.y, BlockFlag.rally, false);
boolean move = true;
if(target != null && !unit.within(target, 70f)){
pathfind(Pathfinder.fieldRally);
//stop moving toward the drop zone if applicable
if(core == null && state.rules.waves && unit.team == state.rules.defaultTeam){
Tile spawner = getClosestSpawner();
if(spawner != null && unit.within(spawner, state.rules.dropZoneRadius + 120f)){
move = false;
}
}else if(command() == UnitCommand.attack && core != null){
pathfind(Pathfinder.fieldCore);
}
if(unit.moving()) unit.lookAt(unit.vel().angle());
if(move){
pathfind(Pathfinder.fieldCore);
}
}
unit.controlWeapons(rotate, shoot);
faceTarget();
}
@Override
protected Teamc target(float x, float y, float range, boolean air, boolean ground){
public Teamc target(float x, float y, float range, boolean air, boolean ground){
return Units.closestTarget(unit.team, x, y, range, u -> u.checkTarget(air, ground), t -> ground &&
!(t.block instanceof Conveyor || t.block instanceof Conduit)); //do not target conveyors/conduits
}
+14 -8
View File
@@ -2,6 +2,7 @@ package mindustry.async;
import arc.*;
import arc.struct.*;
import arc.util.*;
import mindustry.game.EventType.*;
import java.util.concurrent.*;
@@ -10,19 +11,14 @@ import static mindustry.Vars.*;
public class AsyncCore{
//all processes to be executed each frame
private final Seq<AsyncProcess> processes = Seq.with(
public final Seq<AsyncProcess> processes = Seq.with(
new PhysicsProcess()
);
//futures to be awaited
private final Seq<Future<?>> futures = new Seq<>();
private final ExecutorService executor = Executors.newFixedThreadPool(processes.size, r -> {
Thread thread = new Thread(r, "AsyncLogic-Thread");
thread.setDaemon(true);
thread.setUncaughtExceptionHandler((t, e) -> Core.app.post(() -> { throw new RuntimeException(e); }));
return thread;
});
private ExecutorService executor;
public AsyncCore(){
Events.on(WorldLoadEvent.class, e -> {
@@ -49,6 +45,16 @@ public class AsyncCore{
futures.clear();
//init executor with size of potentially-modified process list
if(executor == null){
executor = Executors.newFixedThreadPool(processes.size, r -> {
Thread thread = new Thread(r, "AsyncLogic-Thread");
thread.setDaemon(true);
thread.setUncaughtExceptionHandler((t, e) -> Threads.throwAppException(e));
return thread;
});
}
//submit all tasks
for(AsyncProcess p : processes){
if(p.shouldProcess()){
@@ -71,7 +77,7 @@ public class AsyncCore{
private void complete(){
//wait for all threads to stop processing
for(Future future : futures){
for(var future : futures){
try{
future.get();
}catch(Throwable t){
+40 -26
View File
@@ -10,11 +10,11 @@ import mindustry.entities.*;
import mindustry.gen.*;
public class PhysicsProcess implements AsyncProcess{
private static final int
layers = 3,
layerGround = 0,
layerLegs = 1,
layerFlying = 2;
public static final int
layers = 3,
layerGround = 0,
layerLegs = 1,
layerFlying = 2;
private PhysicsWorld physics;
private Seq<PhysicRef> refs = new Seq<>(false);
@@ -24,6 +24,7 @@ public class PhysicsProcess implements AsyncProcess{
@Override
public void begin(){
if(physics == null) return;
boolean local = !Vars.net.client();
//remove stale entities
refs.removeAll(ref -> {
@@ -35,32 +36,32 @@ public class PhysicsProcess implements AsyncProcess{
return false;
});
//find Unit without bodies and assign them
//find Units without bodies and assign them
for(Unit entity : group){
if(entity == null || entity.type == null || !entity.type.physics) continue;
if(entity.physref() == null){
if(entity.physref == null){
PhysicsBody body = new PhysicsBody();
body.x = entity.x();
body.y = entity.y();
body.x = entity.x;
body.y = entity.y;
body.mass = entity.mass();
body.radius = entity.hitSize() / 2f;
body.radius = entity.hitSize * Vars.unitCollisionRadiusScale;
PhysicRef ref = new PhysicRef(entity, body);
refs.add(ref);
entity.physref(ref);
entity.physref = ref;
physics.add(body);
}
//save last position
PhysicRef ref = entity.physref();
PhysicRef ref = entity.physref;
ref.body.layer =
entity.type.allowLegStep ? layerLegs :
entity.isGrounded() ? layerGround : layerFlying;
ref.x = entity.x();
ref.y = entity.y();
ref.body.layer = entity.collisionLayer();
ref.x = entity.x;
ref.y = entity.y;
ref.body.local = local || entity.isLocal();
}
}
@@ -147,21 +148,30 @@ public class PhysicsProcess implements AsyncProcess{
trees[i].clear();
}
for(int i = 0; i < bodies.size; i++){
PhysicsBody body = bodies.items[i];
var bodyItems = bodies.items;
int bodySize = bodies.size;
for(int i = 0; i < bodySize; i++){
PhysicsBody body = bodyItems[i];
body.collided = false;
trees[body.layer].insert(body);
}
for(int i = 0; i < bodies.size; i++){
PhysicsBody body = bodies.items[i];
for(int i = 0; i < bodySize; i++){
PhysicsBody body = bodyItems[i];
//for clients, the only body that collides is the local one; all other physics simulations are handled by the server.
if(!body.local) continue;
body.hitbox(rect);
seq.size = 0;
trees[body.layer].intersect(rect, seq);
int size = seq.size;
var items = seq.items;
for(int j = 0; j < seq.size; j++){
PhysicsBody other = seq.items[j];
for(int j = 0; j < size; j++){
PhysicsBody other = items[j];
if(other == body || other.collided) continue;
@@ -173,10 +183,14 @@ public class PhysicsProcess implements AsyncProcess{
float ms = body.mass + other.mass;
float m1 = other.mass / ms, m2 = body.mass / ms;
//first body is always local due to guard check above
body.x += vec.x * m1 / scl;
body.y += vec.y * m1 / scl;
other.x -= vec.x * m2 / scl;
other.y -= vec.y * m2 / scl;
if(other.local){
other.x -= vec.x * m2 / scl;
other.y -= vec.y * m2 / scl;
}
}
}
body.collided = true;
@@ -186,7 +200,7 @@ public class PhysicsProcess implements AsyncProcess{
public static class PhysicsBody implements QuadTreeObject{
public float x, y, radius, mass;
public int layer = 0;
public boolean collided = false;
public boolean collided = false, local = true;
@Override
public void hitbox(Rect out){
+28 -8
View File
@@ -17,7 +17,7 @@ import static mindustry.Vars.*;
/** Controls playback of multiple audio tracks.*/
public class SoundControl{
protected static final float finTime = 120f, foutTime = 120f, musicInterval = 60 * 60 * 3f, musicChance = 0.6f, musicWaveChance = 0.46f;
public float finTime = 120f, foutTime = 120f, musicInterval = 3f * Time.toMinutes, musicChance = 0.8f, musicWaveChance = 0.46f;
/** normal, ambient music, plays at any time */
public Seq<Music> ambientMusic = Seq.with();
@@ -28,6 +28,7 @@ public class SoundControl{
protected Music lastRandomPlayed;
protected Interval timer = new Interval(4);
protected long lastPlayed;
protected @Nullable Music current;
protected float fade;
protected boolean silenced;
@@ -55,6 +56,10 @@ public class SoundControl{
}));
setupFilters();
Events.on(ResetEvent.class, e -> {
lastPlayed = Time.millis();
});
}
protected void setupFilters(){
@@ -65,7 +70,7 @@ public class SoundControl{
protected void reload(){
current = null;
fade = 0f;
ambientMusic = Seq.with(Musics.game1, Musics.game3, Musics.game6, Musics.game8, Musics.game9);
ambientMusic = Seq.with(Musics.game1, Musics.game3, Musics.game6, Musics.game8, Musics.game9, Musics.fine);
darkMusic = Seq.with(Musics.game2, Musics.game5, Musics.game7, Musics.game4);
bossMusic = Seq.with(Musics.boss1, Musics.boss2, Musics.game2, Musics.game5);
@@ -76,6 +81,8 @@ public class SoundControl{
sound.setBus(uiBus);
}
}
Events.fire(new MusicRegisterEvent());
}
public void loop(Sound sound, float volume){
@@ -130,14 +137,21 @@ public class SoundControl{
Core.audio.soundBus.play();
setupFilters();
}else{
//stopping a single audio bus stops everything else, yay!
Core.audio.soundBus.stop();
//play music bus again, as it was stopped above
Core.audio.musicBus.play();
Core.audio.soundBus.play();
}
}
Core.audio.setPaused(Core.audio.soundBus.id, state.isPaused());
if(state.isMenu()){
silenced = false;
if(ui.planet.isShown()){
play(Musics.launch);
play(ui.planet.state.planet.launchMusic);
}else if(ui.editor.isShown()){
play(Musics.editor);
}else{
@@ -150,10 +164,14 @@ public class SoundControl{
//this just fades out the last track to make way for ingame music
silence();
//play music at intervals
if(timer.get(musicInterval)){
if(Core.settings.getBool("alwaysmusic")){
if(current == null){
playRandom();
}
}else if(Time.timeSinceMillis(lastPlayed) > 1000 * musicInterval / 60f){
//chance to play it per interval
if(Mathf.chance(musicChance)){
lastPlayed = Time.millis();
playRandom();
}
}
@@ -172,7 +190,7 @@ public class SoundControl{
float avol = Core.settings.getInt("ambientvol", 100) / 100f;
sounds.each((sound, data) -> {
data.curVolume = Mathf.lerpDelta(data.curVolume, data.volume * avol, 0.2f);
data.curVolume = Mathf.lerpDelta(data.curVolume, data.volume * avol, 0.11f);
boolean play = data.curVolume > 0.01f;
float pan = Mathf.zero(data.total, 0.0001f) ? 0f : sound.calcPan(data.sum.x / data.total, data.sum.y / data.total);
@@ -198,7 +216,9 @@ public class SoundControl{
/** Plays a random track.*/
public void playRandom(){
if(isDark()){
if(state.boss() != null){
playOnce(bossMusic.random(lastRandomPlayed));
}else if(isDark()){
playOnce(darkMusic.random(lastRandomPlayed));
}else{
playOnce(ambientMusic.random(lastRandomPlayed));
@@ -207,7 +227,7 @@ public class SoundControl{
/** Whether to play dark music.*/
protected boolean isDark(){
if(state.teams.get(player.team()).hasCore() && state.teams.get(player.team()).core().healthf() < 0.85f){
if(player.team().data().hasCore() && player.team().data().core().healthf() < 0.85f){
//core damaged -> dark
return true;
}
+6 -2
View File
@@ -19,11 +19,15 @@ public class SoundLoop{
}
public void update(float x, float y, boolean play){
update(x, y, play, 1f);
}
public void update(float x, float y, boolean play, float volumeScl){
if(baseVolume <= 0) return;
if(id < 0){
if(play){
id = sound.loop(sound.calcVolume(x, y) * volume * baseVolume, 1f, sound.calcPan(x, y));
id = sound.loop(sound.calcVolume(x, y) * volume * baseVolume * volumeScl, 1f, sound.calcPan(x, y));
}
}else{
//fade the sound in or out
@@ -38,7 +42,7 @@ public class SoundLoop{
}
}
Core.audio.set(id, sound.calcPan(x, y), sound.calcVolume(x, y) * volume * baseVolume);
Core.audio.set(id, sound.calcPan(x, y), sound.calcVolume(x, y) * volume * baseVolume * volumeScl);
}
}
File diff suppressed because it is too large Load Diff
+21 -481
View File
@@ -1,46 +1,26 @@
package mindustry.content;
import arc.graphics.*;
import arc.graphics.g2d.*;
import arc.math.*;
import arc.util.*;
import mindustry.ctype.*;
import mindustry.entities.*;
import mindustry.entities.bullet.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.io.*;
import mindustry.world.*;
import static mindustry.Vars.*;
public class Bullets implements ContentList{
/**
* Class for holding special internal bullets.
* Formerly used to define preset bullets for turrets; as of v7, these have been inlined at the source.
* */
public class Bullets{
public static BulletType
//artillery
artilleryDense, artilleryPlastic, artilleryPlasticFrag, artilleryHoming, artilleryIncendiary, artilleryExplosive,
placeholder, spaceLiquid, damageLightning, damageLightningGround, damageLightningAir, fireball;
//flak
flakScrap, flakLead, flakGlass, flakGlassFrag,
public static void load(){
//frag (flak-like but hits ground)
fragGlass, fragExplosive, fragPlastic, fragSurge, fragGlassFrag, fragPlasticFrag,
//missiles
missileExplosive, missileIncendiary, missileSurge,
//standard
standardCopper, standardDense, standardThorium, standardHoming, standardIncendiary,
standardDenseBig, standardThoriumBig, standardIncendiaryBig,
//liquid
waterShot, cryoShot, slagShot, oilShot, heavyWaterShot, heavyCryoShot, heavySlagShot, heavyOilShot,
//environment, misc.
damageLightning, damageLightningGround, fireball, basicFlame, pyraFlame, driverBolt;
@Override
public void load(){
//not allowed in weapons - used only to prevent NullPointerExceptions
placeholder = new BasicBulletType(2.5f, 9, "ohno"){{
width = 7f;
height = 9f;
lifetime = 60f;
ammoMultiplier = 2;
}};
//lightning bullets need to be initialized first.
damageLightning = new BulletType(0.0001f, 0f){{
@@ -50,464 +30,24 @@ public class Bullets implements ContentList{
status = StatusEffects.shocked;
statusDuration = 10f;
hittable = false;
lightColor = Color.white;
}};
//this is just a copy of the damage lightning bullet that doesn't damage air units
damageLightningGround = new BulletType(0.0001f, 0f){};
JsonIO.copy(damageLightning, damageLightningGround);
damageLightningGround = damageLightning.copy();
damageLightningGround.collidesAir = false;
artilleryDense = new ArtilleryBulletType(3f, 20, "shell"){{
hitEffect = Fx.flakExplosion;
knockback = 0.8f;
lifetime = 80f;
width = height = 11f;
collidesTiles = false;
splashDamageRadius = 25f;
splashDamage = 33f;
}};
damageLightningAir = damageLightning.copy();
damageLightningAir.collidesGround = false;
damageLightningAir.collidesTiles = false;
artilleryPlasticFrag = new BasicBulletType(2.5f, 10, "bullet"){{
width = 10f;
height = 12f;
shrinkY = 1f;
lifetime = 15f;
backColor = Pal.plastaniumBack;
frontColor = Pal.plastaniumFront;
despawnEffect = Fx.none;
collidesAir = false;
}};
artilleryPlastic = new ArtilleryBulletType(3.4f, 20, "shell"){{
hitEffect = Fx.plasticExplosion;
knockback = 1f;
lifetime = 80f;
width = height = 13f;
collidesTiles = false;
splashDamageRadius = 35f;
splashDamage = 45f;
fragBullet = artilleryPlasticFrag;
fragBullets = 10;
backColor = Pal.plastaniumBack;
frontColor = Pal.plastaniumFront;
}};
artilleryHoming = new ArtilleryBulletType(3f, 20, "shell"){{
hitEffect = Fx.flakExplosion;
knockback = 0.8f;
lifetime = 80f;
width = height = 11f;
collidesTiles = false;
splashDamageRadius = 25f;
splashDamage = 33f;
reloadMultiplier = 1.2f;
ammoMultiplier = 3f;
homingPower = 0.08f;
homingRange = 50f;
}};
artilleryIncendiary = new ArtilleryBulletType(3f, 20, "shell"){{
hitEffect = Fx.blastExplosion;
knockback = 0.8f;
lifetime = 80f;
width = height = 13f;
collidesTiles = false;
splashDamageRadius = 25f;
splashDamage = 35f;
status = StatusEffects.burning;
frontColor = Pal.lightishOrange;
backColor = Pal.lightOrange;
makeFire = true;
trailEffect = Fx.incendTrail;
}};
artilleryExplosive = new ArtilleryBulletType(2f, 20, "shell"){{
hitEffect = Fx.blastExplosion;
knockback = 0.8f;
lifetime = 80f;
width = height = 14f;
collidesTiles = false;
ammoMultiplier = 4f;
splashDamageRadius = 45f;
splashDamage = 50f;
backColor = Pal.missileYellowBack;
frontColor = Pal.missileYellow;
status = StatusEffects.blasted;
statusDuration = 60f;
}};
flakGlassFrag = new BasicBulletType(3f, 5, "bullet"){{
width = 5f;
height = 12f;
shrinkY = 1f;
lifetime = 20f;
backColor = Pal.gray;
frontColor = Color.white;
despawnEffect = Fx.none;
collidesGround = false;
}};
flakLead = new FlakBulletType(4.2f, 3){{
lifetime = 60f;
ammoMultiplier = 4f;
shootEffect = Fx.shootSmall;
width = 6f;
height = 8f;
hitEffect = Fx.flakExplosion;
splashDamage = 27f;
splashDamageRadius = 15f;
}};
flakScrap = new FlakBulletType(4f, 3){{
lifetime = 60f;
ammoMultiplier = 5f;
shootEffect = Fx.shootSmall;
reloadMultiplier = 0.5f;
width = 6f;
height = 8f;
hitEffect = Fx.flakExplosion;
splashDamage = 22f;
splashDamageRadius = 24f;
}};
flakGlass = new FlakBulletType(4f, 3){{
lifetime = 60f;
ammoMultiplier = 5f;
shootEffect = Fx.shootSmall;
reloadMultiplier = 0.8f;
width = 6f;
height = 8f;
hitEffect = Fx.flakExplosion;
splashDamage = 22f;
splashDamageRadius = 20f;
fragBullet = flakGlassFrag;
fragBullets = 5;
}};
fragGlassFrag = new BasicBulletType(3f, 5, "bullet"){{
width = 5f;
height = 12f;
shrinkY = 1f;
lifetime = 20f;
backColor = Pal.gray;
frontColor = Color.white;
despawnEffect = Fx.none;
}};
fragPlasticFrag = new BasicBulletType(2.5f, 10, "bullet"){{
width = 10f;
height = 12f;
shrinkY = 1f;
lifetime = 15f;
backColor = Pal.plastaniumBack;
frontColor = Pal.plastaniumFront;
despawnEffect = Fx.none;
}};
fragGlass = new FlakBulletType(4f, 3){{
ammoMultiplier = 3f;
shootEffect = Fx.shootSmall;
reloadMultiplier = 0.8f;
width = 6f;
height = 8f;
hitEffect = Fx.flakExplosion;
splashDamage = 18f;
splashDamageRadius = 16f;
fragBullet = fragGlassFrag;
fragBullets = 3;
explodeRange = 20f;
collidesGround = true;
}};
fragPlastic = new FlakBulletType(4f, 6){{
splashDamageRadius = 40f;
splashDamage = 25f;
fragBullet = fragPlasticFrag;
fragBullets = 5;
hitEffect = Fx.plasticExplosion;
frontColor = Pal.plastaniumFront;
backColor = Pal.plastaniumBack;
shootEffect = Fx.shootBig;
collidesGround = true;
explodeRange = 20f;
}};
fragExplosive = new FlakBulletType(4f, 5){{
shootEffect = Fx.shootBig;
ammoMultiplier = 4f;
splashDamage = 18f;
splashDamageRadius = 55f;
collidesGround = true;
status = StatusEffects.blasted;
statusDuration = 60f;
}};
fragSurge = new FlakBulletType(4.5f, 13){{
ammoMultiplier = 4f;
splashDamage = 50f;
splashDamageRadius = 40f;
lightning = 2;
lightningLength = 7;
shootEffect = Fx.shootBig;
collidesGround = true;
explodeRange = 20f;
}};
missileExplosive = new MissileBulletType(3.7f, 10){{
width = 8f;
height = 8f;
shrinkY = 0f;
drag = -0.01f;
splashDamageRadius = 30f;
splashDamage = 30f;
ammoMultiplier = 4f;
hitEffect = Fx.blastExplosion;
despawnEffect = Fx.blastExplosion;
status = StatusEffects.blasted;
statusDuration = 60f;
}};
missileIncendiary = new MissileBulletType(3.7f, 12){{
frontColor = Pal.lightishOrange;
backColor = Pal.lightOrange;
width = 7f;
height = 8f;
shrinkY = 0f;
drag = -0.01f;
homingPower = 0.08f;
splashDamageRadius = 20f;
splashDamage = 20f;
makeFire = true;
hitEffect = Fx.blastExplosion;
status = StatusEffects.burning;
}};
missileSurge = new MissileBulletType(3.7f, 18){{
width = 8f;
height = 8f;
shrinkY = 0f;
drag = -0.01f;
splashDamageRadius = 25f;
splashDamage = 25f;
hitEffect = Fx.blastExplosion;
despawnEffect = Fx.blastExplosion;
lightningDamage = 10;
lightning = 2;
lightningLength = 10;
}};
standardCopper = new BasicBulletType(2.5f, 9){{
width = 7f;
height = 9f;
lifetime = 60f;
shootEffect = Fx.shootSmall;
smokeEffect = Fx.shootSmallSmoke;
ammoMultiplier = 2;
}};
standardDense = new BasicBulletType(3.5f, 18){{
width = 9f;
height = 12f;
reloadMultiplier = 0.6f;
ammoMultiplier = 4;
lifetime = 60f;
}};
standardThorium = new BasicBulletType(4f, 29, "bullet"){{
width = 10f;
height = 13f;
shootEffect = Fx.shootBig;
smokeEffect = Fx.shootBigSmoke;
ammoMultiplier = 4;
lifetime = 60f;
}};
standardHoming = new BasicBulletType(3f, 12, "bullet"){{
width = 7f;
height = 9f;
homingPower = 0.08f;
reloadMultiplier = 1.5f;
ammoMultiplier = 5;
lifetime = 60f;
}};
standardIncendiary = new BasicBulletType(3.2f, 11, "bullet"){{
width = 10f;
height = 12f;
frontColor = Pal.lightishOrange;
backColor = Pal.lightOrange;
status = StatusEffects.burning;
makeFire = true;
inaccuracy = 3f;
lifetime = 60f;
}};
standardDenseBig = new BasicBulletType(7f, 55, "bullet"){{
width = 15f;
height = 21f;
shootEffect = Fx.shootBig;
}};
standardThoriumBig = new BasicBulletType(8f, 80, "bullet"){{
width = 16f;
height = 23f;
shootEffect = Fx.shootBig;
pierceCap = 2;
pierceBuilding = true;
knockback = 0.7f;
}};
standardIncendiaryBig = new BasicBulletType(7f, 60, "bullet"){{
width = 16f;
height = 21f;
frontColor = Pal.lightishOrange;
backColor = Pal.lightOrange;
status = StatusEffects.burning;
shootEffect = Fx.shootBig;
makeFire = true;
pierceCap = 2;
pierceBuilding = true;
knockback = 0.7f;
}};
fireball = new BulletType(1f, 4){
{
pierce = true;
collidesTiles = false;
collides = false;
drag = 0.03f;
hitEffect = despawnEffect = Fx.none;
}
@Override
public void init(Bullet b){
b.vel.setLength(0.6f + Mathf.random(2f));
}
@Override
public void draw(Bullet b){
Draw.color(Pal.lightFlame, Pal.darkFlame, Color.gray, b.fin());
Fill.circle(b.x, b.y, 3f * b.fout());
Draw.reset();
}
@Override
public void update(Bullet b){
if(Mathf.chance(0.04 * Time.delta)){
Tile tile = world.tileWorld(b.x, b.y);
if(tile != null){
Fires.create(tile);
}
}
if(Mathf.chance(0.1 * Time.delta)){
Fx.fireballsmoke.at(b.x, b.y);
}
if(Mathf.chance(0.1 * Time.delta)){
Fx.ballfire.at(b.x, b.y);
}
}
};
basicFlame = new BulletType(3.35f, 16f){{
ammoMultiplier = 3f;
hitSize = 7f;
lifetime = 18f;
pierce = true;
collidesAir = false;
statusDuration = 60f * 4;
shootEffect = Fx.shootSmallFlame;
hitEffect = Fx.hitFlameSmall;
despawnEffect = Fx.none;
status = StatusEffects.burning;
keepVelocity = false;
fireball = new FireBulletType(1f, 4){{
hittable = false;
}};
pyraFlame = new BulletType(3.35f, 25f){{
ammoMultiplier = 4f;
hitSize = 7f;
lifetime = 18f;
pierce = true;
collidesAir = false;
statusDuration = 60f * 6;
shootEffect = Fx.shootPyraFlame;
hitEffect = Fx.hitFlameSmall;
despawnEffect = Fx.none;
status = StatusEffects.burning;
hittable = false;
}};
waterShot = new LiquidBulletType(Liquids.water){{
spaceLiquid = new SpaceLiquidBulletType(){{
knockback = 0.7f;
drag = 0.01f;
}};
cryoShot = new LiquidBulletType(Liquids.cryofluid){{
drag = 0.01f;
}};
slagShot = new LiquidBulletType(Liquids.slag){{
damage = 4;
drag = 0.01f;
}};
oilShot = new LiquidBulletType(Liquids.oil){{
drag = 0.01f;
}};
heavyWaterShot = new LiquidBulletType(Liquids.water){{
lifetime = 49f;
speed = 4f;
knockback = 1.7f;
puddleSize = 8f;
orbSize = 4f;
drag = 0.001f;
ammoMultiplier = 0.4f;
statusDuration = 60f * 4f;
damage = 0.2f;
}};
heavyCryoShot = new LiquidBulletType(Liquids.cryofluid){{
lifetime = 49f;
speed = 4f;
knockback = 1.3f;
puddleSize = 8f;
orbSize = 4f;
drag = 0.001f;
ammoMultiplier = 0.4f;
statusDuration = 60f * 4f;
damage = 0.2f;
}};
heavySlagShot = new LiquidBulletType(Liquids.slag){{
lifetime = 49f;
speed = 4f;
knockback = 1.3f;
puddleSize = 8f;
orbSize = 4f;
damage = 4.75f;
drag = 0.001f;
ammoMultiplier = 0.4f;
statusDuration = 60f * 4f;
}};
heavyOilShot = new LiquidBulletType(Liquids.oil){{
lifetime = 49f;
speed = 4f;
knockback = 1.3f;
puddleSize = 8f;
orbSize = 4f;
drag = 0.001f;
ammoMultiplier = 0.4f;
statusDuration = 60f * 4f;
damage = 0.2f;
}};
driverBolt = new MassDriverBolt();
}
}
@@ -0,0 +1,468 @@
package mindustry.content;
import arc.struct.*;
import arc.util.*;
import mindustry.entities.bullet.*;
import mindustry.game.Objectives.*;
import mindustry.type.*;
import mindustry.type.unit.*;
import mindustry.world.blocks.defense.turrets.*;
import static mindustry.Vars.*;
import static mindustry.content.Blocks.*;
import static mindustry.content.SectorPresets.*;
import static mindustry.content.TechTree.*;
public class ErekirTechTree{
static IntSet balanced = new IntSet();
static void rebalanceBullet(BulletType bullet){
if(balanced.add(bullet.id)){
bullet.damage *= 0.75f;
}
}
//TODO remove this
public static void rebalance(){
for(var unit : content.units().select(u -> u instanceof ErekirUnitType)){
for(var weapon : unit.weapons){
rebalanceBullet(weapon.bullet);
}
}
for(var block : content.blocks()){
if(block instanceof Turret turret && Structs.contains(block.requirements, i -> !Items.serpuloItems.contains(i.item))){
if(turret instanceof ItemTurret item){
for(var bullet : item.ammoTypes.values()){
rebalanceBullet(bullet);
}
}else if(turret instanceof ContinuousLiquidTurret cont){
for(var bullet : cont.ammoTypes.values()){
rebalanceBullet(bullet);
}
}else if(turret instanceof ContinuousTurret cont){
rebalanceBullet(cont.shootType);
}
}
}
}
public static void load(){
rebalance();
//TODO might be unnecessary with no asteroids
Seq<Objective> erekirSector = Seq.with(new OnPlanet(Planets.erekir));
var costMultipliers = new ObjectFloatMap<Item>();
for(var item : content.items()) costMultipliers.put(item, 0.9f);
//these are hard to make
costMultipliers.put(Items.oxide, 0.5f);
costMultipliers.put(Items.surgeAlloy, 0.7f);
costMultipliers.put(Items.carbide, 0.3f);
costMultipliers.put(Items.phaseFabric, 0.2f);
Planets.erekir.techTree = nodeRoot("erekir", coreBastion, true, () -> {
context().researchCostMultipliers = costMultipliers;
node(duct, erekirSector, () -> {
node(ductRouter, () -> {
node(ductBridge, () -> {
node(armoredDuct, () -> {
node(surgeConveyor, () -> {
node(surgeRouter);
});
});
node(unitCargoLoader, () -> {
node(unitCargoUnloadPoint, () -> {
});
});
});
node(overflowDuct, Seq.with(new OnSector(aegis)), () -> {
node(underflowDuct);
node(reinforcedContainer, () -> {
node(ductUnloader, () -> {
});
node(reinforcedVault, () -> {
});
});
});
node(reinforcedMessage, Seq.with(new OnSector(aegis)), () -> {
node(canvas);
});
});
node(reinforcedPayloadConveyor, Seq.with(new OnSector(atlas)), () -> {
//TODO should only be unlocked in unit sector
node(payloadMassDriver, Seq.with(new Research(siliconArcFurnace), new OnSector(split)), () -> {
//TODO further limitations
node(payloadLoader, () -> {
node(payloadUnloader, () -> {
node(largePayloadMassDriver, () -> {
});
});
});
node(constructor, Seq.with(new OnSector(split)), () -> {
node(smallDeconstructor, Seq.with(new OnSector(peaks)), () -> {
node(largeConstructor, Seq.with(new OnSector(siege)), () -> {
});
node(deconstructor, Seq.with(new OnSector(siege)), () -> {
});
});
});
});
node(reinforcedPayloadRouter, () -> {
});
});
});
//TODO move into turbine condenser?
node(plasmaBore, () -> {
node(impactDrill, Seq.with(new OnSector(aegis)), () -> {
node(largePlasmaBore, Seq.with(new OnSector(caldera)), () -> {
node(eruptionDrill, Seq.with(new OnSector(stronghold)), () -> {
});
node(largeCliffCrusher, Seq.with(new OnSector(stronghold)), () -> {
});
});
});
});
node(turbineCondenser, () -> {
node(beamNode, () -> {
node(ventCondenser, Seq.with(new OnSector(aegis)), () -> {
node(chemicalCombustionChamber, Seq.with(new OnSector(basin)), () -> {
node(pyrolysisGenerator, Seq.with(new OnSector(crevice)), () -> {
node(fluxReactor, Seq.with(new OnSector(crossroads), new Research(cyanogenSynthesizer)), () -> {
node(neoplasiaReactor, Seq.with(new OnSector(karst)), () -> {
});
});
});
});
});
node(beamTower, Seq.with(new OnSector(peaks)), () -> {
});
node(regenProjector, Seq.with(new OnSector(peaks)), () -> {
//TODO more tiers of build tower or "support" structures like overdrive projectors
node(buildTower, Seq.with(new OnSector(stronghold)), () -> {
node(shockwaveTower, Seq.with(new OnSector(siege)), () -> {
});
});
});
});
node(reinforcedConduit, Seq.with(new OnSector(aegis)), () -> {
//TODO maybe should be even later
node(reinforcedPump, Seq.with(new OnSector(basin)), () -> {
//TODO T2 pump, consume cyanogen or similar
});
node(reinforcedLiquidJunction, () -> {
node(reinforcedBridgeConduit, () -> {
});
node(reinforcedLiquidRouter, () -> {
node(reinforcedLiquidContainer, () -> {
node(reinforcedLiquidTank, Seq.with(new SectorComplete(intersect)), () -> {
});
});
});
});
});
node(cliffCrusher, () -> {
node(siliconArcFurnace, () -> {
node(electrolyzer, Seq.with(new OnSector(atlas)), () -> {
node(oxidationChamber, Seq.with(new Research(tankRefabricator), new OnSector(marsh)), () -> {
node(surgeCrucible, Seq.with(new OnSector(ravine)), () -> {
});
node(heatRedirector, Seq.with(new OnSector(ravine)), () -> {
node(electricHeater, Seq.with(new OnSector(ravine), new Research(afflict)), () -> {
node(slagHeater, Seq.with(new OnSector(caldera)), () -> {
});
node(atmosphericConcentrator, Seq.with(new OnSector(caldera)), () -> {
node(cyanogenSynthesizer, Seq.with(new OnSector(siege)), () -> {
});
});
node(carbideCrucible, Seq.with(new OnSector(crevice)), () -> {
node(phaseSynthesizer, Seq.with(new OnSector(karst)), () -> {
node(phaseHeater, Seq.with(new Research(phaseSynthesizer)), () -> {
});
});
});
node(heatRouter, () -> {
node(smallHeatRedirector, () -> {
});
});
});
});
});
node(slagIncinerator, Seq.with(new OnSector(basin)), () -> {
//TODO these are unused.
//node(slagCentrifuge, () -> {});
//node(heatReactor, () -> {});
});
});
});
});
});
node(breach, Seq.with(new Research(siliconArcFurnace), new Research(tankFabricator)), () -> {
node(berylliumWall, () -> {
node(berylliumWallLarge, () -> {
});
node(tungstenWall, () -> {
node(tungstenWallLarge, () -> {
node(blastDoor, () -> {
});
});
node(reinforcedSurgeWall, () -> {
node(reinforcedSurgeWallLarge, () -> {
node(shieldedWall, () -> {
});
});
});
node(carbideWall, () -> {
node(carbideWallLarge, () -> {
});
});
});
});
node(diffuse, Seq.with(new OnSector(lake)), () -> {
node(sublimate, Seq.with(new OnSector(marsh)), () -> {
node(afflict, Seq.with(new OnSector(ravine)), () -> {
node(titan, Seq.with(new OnSector(stronghold)), () -> {
node(lustre, Seq.with(new OnSector(crevice)), () -> {
node(smite, Seq.with(new OnSector(karst)), () -> {
});
});
});
});
});
node(disperse, Seq.with(new OnSector(stronghold)), () -> {
node(scathe, Seq.with(new OnSector(siege)), () -> {
node(malign, Seq.with(new SectorComplete(karst)), () -> {
});
});
});
});
node(radar, Seq.with(new Research(beamNode), new Research(turbineCondenser), new Research(tankFabricator), new OnSector(SectorPresets.aegis)), () -> {
});
});
node(coreCitadel, Seq.with(new SectorComplete(peaks)), () -> {
node(coreAcropolis, Seq.with(new SectorComplete(siege)), () -> {
});
});
node(tankFabricator, Seq.with(new Research(siliconArcFurnace), new Research(plasmaBore), new Research(turbineCondenser)), () -> {
node(UnitTypes.stell);
node(unitRepairTower, Seq.with(new OnSector(ravine), new Research(mechRefabricator)), () -> {
});
node(shipFabricator, Seq.with(new OnSector(lake)), () -> {
node(UnitTypes.elude);
node(mechFabricator, Seq.with(new OnSector(intersect)), () -> {
node(UnitTypes.merui);
node(tankRefabricator, Seq.with(new OnSector(atlas)), () -> {
node(UnitTypes.locus);
node(mechRefabricator, Seq.with(new OnSector(basin)), () -> {
node(UnitTypes.cleroi);
node(shipRefabricator, Seq.with(new OnSector(peaks)), () -> {
node(UnitTypes.avert);
//TODO
node(primeRefabricator, Seq.with(new OnSector(stronghold)), () -> {
node(UnitTypes.precept);
node(UnitTypes.anthicus);
node(UnitTypes.obviate);
});
node(tankAssembler, Seq.with(new OnSector(siege), new Research(constructor), new Research(atmosphericConcentrator)), () -> {
node(UnitTypes.vanquish, () -> {
node(UnitTypes.conquer, Seq.with(new OnSector(karst)), () -> {
});
});
node(shipAssembler, Seq.with(new OnSector(crossroads)), () -> {
node(UnitTypes.quell, () -> {
node(UnitTypes.disrupt, Seq.with(new OnSector(karst)), () -> {
});
});
});
node(mechAssembler, Seq.with(new OnSector(crossroads)), () -> {
node(UnitTypes.tecta, () -> {
node(UnitTypes.collaris, Seq.with(new OnSector(karst)), () -> {
});
});
});
node(basicAssemblerModule, Seq.with(new SectorComplete(karst)), () -> {
});
});
});
});
});
});
});
});
node(onset, () -> {
node(aegis, Seq.with(new SectorComplete(onset), new Research(ductRouter), new Research(ductBridge)), () -> {
node(lake, Seq.with(new SectorComplete(aegis)), () -> {
});
node(intersect, Seq.with(new SectorComplete(aegis), new SectorComplete(lake), new Research(ventCondenser), new Research(shipFabricator)), () -> {
node(atlas, Seq.with(new SectorComplete(intersect), new Research(mechFabricator)), () -> {
node(split, Seq.with(new SectorComplete(atlas), new Research(reinforcedPayloadConveyor), new Research(reinforcedContainer)), () -> {
});
node(basin, Seq.with(new SectorComplete(atlas)), () -> {
node(marsh, Seq.with(new SectorComplete(basin)), () -> {
node(ravine, Seq.with(new SectorComplete(marsh), new Research(Liquids.slag)), () -> {
node(caldera, Seq.with(new SectorComplete(peaks), new Research(heatRedirector)), () -> {
node(stronghold, Seq.with(new SectorComplete(caldera), new Research(coreCitadel)), () -> {
node(crevice, Seq.with(new SectorComplete(stronghold)), () -> {
node(siege, Seq.with(new SectorComplete(crevice)), () -> {
node(crossroads, Seq.with(new SectorComplete(siege)), () -> {
node(karst, Seq.with(new SectorComplete(crossroads), new Research(coreAcropolis)), () -> {
node(origin, Seq.with(new SectorComplete(karst), new Research(coreAcropolis), new Research(UnitTypes.vanquish), new Research(UnitTypes.disrupt), new Research(UnitTypes.collaris), new Research(malign), new Research(basicAssemblerModule), new Research(neoplasiaReactor)), () -> {
});
});
});
});
});
});
});
});
node(peaks, Seq.with(new SectorComplete(marsh), new SectorComplete(split)), () -> {
});
});
});
});
});
});
});
nodeProduce(Items.beryllium, () -> {
nodeProduce(Items.sand, () -> {
nodeProduce(Items.silicon, () -> {
nodeProduce(Items.oxide, () -> {
//nodeProduce(Items.fissileMatter, () -> {});
});
});
});
nodeProduce(Liquids.water, () -> {
nodeProduce(Liquids.ozone, () -> {
nodeProduce(Liquids.hydrogen, () -> {
nodeProduce(Liquids.nitrogen, () -> {
});
nodeProduce(Liquids.cyanogen, () -> {
nodeProduce(Liquids.neoplasm, () -> {
});
});
});
});
});
nodeProduce(Items.graphite, () -> {
nodeProduce(Items.tungsten, () -> {
nodeProduce(Liquids.slag, () -> {
});
nodeProduce(Liquids.arkycite, () -> {
});
nodeProduce(Items.thorium, () -> {
nodeProduce(Items.carbide, () -> {
//nodeProduce(Liquids.gallium, () -> {});
});
});
nodeProduce(Items.surgeAlloy, () -> {
nodeProduce(Items.phaseFabric, () -> {
});
});
});
});
});
});
}
}
File diff suppressed because it is too large Load Diff
+67 -9
View File
@@ -1,15 +1,18 @@
package mindustry.content;
import arc.graphics.*;
import mindustry.ctype.*;
import arc.struct.*;
import mindustry.type.*;
public class Items implements ContentList{
public static Item scrap, copper, lead, graphite, coal, titanium, thorium, silicon, plastanium, phaseFabric, surgeAlloy,
sporePod, sand, blastCompound, pyratite, metaglass;
public class Items{
public static Item
scrap, copper, lead, graphite, coal, titanium, thorium, silicon, plastanium,
phaseFabric, surgeAlloy, sporePod, sand, blastCompound, pyratite, metaglass,
beryllium, tungsten, oxide, carbide, fissileMatter, dormantCyst;
@Override
public void load(){
public static final Seq<Item> serpuloItems = new Seq<>(), erekirItems = new Seq<>(), erekirOnlyItems = new Seq<>();
public static void load(){
copper = new Item("copper", Color.valueOf("d99d73")){{
hardness = 1;
cost = 0.5f;
@@ -19,7 +22,6 @@ public class Items implements ContentList{
lead = new Item("lead", Color.valueOf("8c7fa9")){{
hardness = 1;
cost = 0.7f;
alwaysUnlocked = true;
}};
metaglass = new Item("metaglass", Color.valueOf("ebeef5")){{
@@ -31,14 +33,17 @@ public class Items implements ContentList{
}};
sand = new Item("sand", Color.valueOf("f7cba4")){{
alwaysUnlocked = true;
lowPriority = true;
buildable = false;
//needed to show up as requirement
alwaysUnlocked = true;
}};
coal = new Item("coal", Color.valueOf("272727")){{
explosiveness = 0.2f;
flammability = 1f;
hardness = 2;
buildable = false;
}};
titanium = new Item("titanium", Color.valueOf("8da1e3")){{
@@ -51,10 +56,11 @@ public class Items implements ContentList{
hardness = 4;
radioactivity = 1f;
cost = 1.1f;
healthScaling = 0.2f;
}};
scrap = new Item("scrap", Color.valueOf("777777")){{
cost = 0.5f;
}};
silicon = new Item("silicon", Color.valueOf("53565c")){{
@@ -65,29 +71,81 @@ public class Items implements ContentList{
flammability = 0.1f;
explosiveness = 0.2f;
cost = 1.3f;
healthScaling = 0.1f;
}};
phaseFabric = new Item("phase-fabric", Color.valueOf("f4ba6e")){{
cost = 1.3f;
radioactivity = 0.6f;
healthScaling = 0.25f;
}};
surgeAlloy = new Item("surge-alloy", Color.valueOf("f3e979")){{
cost = 1.2f;
charge = 0.75f;
healthScaling = 0.25f;
}};
sporePod = new Item("spore-pod", Color.valueOf("7457ce")){{
flammability = 1.15f;
buildable = false;
}};
blastCompound = new Item("blast-compound", Color.valueOf("ff795e")){{
flammability = 0.4f;
explosiveness = 1.2f;
buildable = false;
}};
pyratite = new Item("pyratite", Color.valueOf("ffaa5f")){{
flammability = 1.4f;
explosiveness = 0.4f;
buildable = false;
}};
beryllium = new Item("beryllium", Color.valueOf("3a8f64")){{
hardness = 3;
cost = 1.2f;
healthScaling = 0.6f;
}};
tungsten = new Item("tungsten", Color.valueOf("768a9a")){{
hardness = 5;
cost = 1.5f;
healthScaling = 0.8f;
}};
oxide = new Item("oxide", Color.valueOf("e4ffd6")){{
cost = 1.2f;
healthScaling = 0.5f;
}};
carbide = new Item("carbide", Color.valueOf("89769a")){{
cost = 1.4f;
healthScaling = 1.1f;
}};
fissileMatter = new Item("fissile-matter", Color.valueOf("5e988d")){{
radioactivity = 1.5f;
hidden = true;
}};
dormantCyst = new Item("dormant-cyst", Color.valueOf("df824d")){{
flammability = 0.1f;
hidden = true;
}};
serpuloItems.addAll(
scrap, copper, lead, graphite, coal, titanium, thorium, silicon, plastanium,
phaseFabric, surgeAlloy, sporePod, sand, blastCompound, pyratite, metaglass
);
erekirItems.addAll(
graphite, thorium, silicon, phaseFabric, surgeAlloy, sand,
beryllium, tungsten, oxide, carbide, fissileMatter, dormantCyst
);
erekirOnlyItems.addAll(erekirItems).removeAll(serpuloItems);
}
}
+62 -7
View File
@@ -1,19 +1,21 @@
package mindustry.content;
import arc.graphics.*;
import mindustry.ctype.*;
import mindustry.type.*;
public class Liquids implements ContentList{
public static Liquid water, slag, oil, cryofluid;
public class Liquids{
public static Liquid water, slag, oil, cryofluid,
arkycite, gallium, neoplasm,
ozone, hydrogen, nitrogen, cyanogen;
@Override
public void load(){
public static void load(){
water = new Liquid("water", Color.valueOf("596ab8")){{
heatCapacity = 0.4f;
alwaysUnlocked = true;
effect = StatusEffects.wet;
boilPoint = 0.5f;
gasColor = Color.grays(0.9f);
alwaysUnlocked = true;
}};
slag = new Liquid("slag", Color.valueOf("ffa166")){{
@@ -24,12 +26,15 @@ public class Liquids implements ContentList{
}};
oil = new Liquid("oil", Color.valueOf("313131")){{
viscosity = 0.7f;
viscosity = 0.75f;
flammability = 1.2f;
explosiveness = 1.2f;
heatCapacity = 0.7f;
barColor = Color.valueOf("6b675f");
effect = StatusEffects.tarred;
boilPoint = 0.65f;
gasColor = Color.grays(0.4f);
canStayOn.add(water);
}};
cryofluid = new Liquid("cryofluid", Color.valueOf("6ecdec")){{
@@ -37,6 +42,56 @@ public class Liquids implements ContentList{
temperature = 0.25f;
effect = StatusEffects.freezing;
lightColor = Color.valueOf("0097f5").a(0.2f);
boilPoint = 0.55f;
gasColor = Color.valueOf("c1e8f5");
}};
neoplasm = new CellLiquid("neoplasm", Color.valueOf("c33e2b")){{
heatCapacity = 0.4f;
temperature = 0.54f;
viscosity = 0.85f;
flammability = 0f;
capPuddles = false;
spreadTarget = Liquids.water;
moveThroughBlocks = true;
incinerable = false;
blockReactive = false;
canStayOn.addAll(water, oil, cryofluid);
colorFrom = Color.valueOf("e8803f");
colorTo = Color.valueOf("8c1225");
}};
arkycite = new Liquid("arkycite", Color.valueOf("84a94b")){{
flammability = 0.4f;
viscosity = 0.7f;
neoplasm.canStayOn.add(this);
}};
gallium = new Liquid("gallium", Color.valueOf("9a9dbf")){{
coolant = false;
hidden = true;
}};
ozone = new Liquid("ozone", Color.valueOf("fc81dd")){{
gas = true;
barColor = Color.valueOf("d699f0");
explosiveness = 1f;
flammability = 1f;
}};
hydrogen = new Liquid("hydrogen", Color.valueOf("9eabf7")){{
gas = true;
flammability = 1f;
}};
nitrogen = new Liquid("nitrogen", Color.valueOf("efe3ff")){{
gas = true;
}};
cyanogen = new Liquid("cyanogen", Color.valueOf("89e8b6")){{
gas = true;
flammability = 2f;
}};
}
}
+8 -8
View File
@@ -1,18 +1,18 @@
package mindustry.content;
import mindustry.ctype.*;
import mindustry.game.*;
public class Loadouts implements ContentList{
public class Loadouts{
public static Schematic
basicShard,
basicFoundation,
basicNucleus;
basicNucleus,
basicBastion;
@Override
public void load(){
basicShard = Schematics.readBase64("bXNjaAB4nD2K2wqAIBiD5ymibnoRn6YnEP1BwUMoBL19FuJ2sbFvUFgYZDaJsLeQrkinN9UJHImsNzlYE7WrIUastuSbnlKx2VJJt+8IQGGKdfO/8J5yrGJSMegLg+YUIA==");
basicFoundation = Schematics.readBase64("bXNjaAB4nD1OSQ6DMBBzFhVu8BG+0X8MQyoiJTNSukj8nlCi2Adbtg/GA4OBF8oB00rvyE/9ykafqOIw58A7SWRKy1ZiShhZ5RcOLZhYS1hefQ1gRIeptH9jq/qW2lvc1d2tgWsOfVX/tOwE86AYBA==");
basicNucleus = Schematics.readBase64("bXNjaAB4nD2MUQqAIBBEJy0s6qOLdJXuYNtCgikYBd2+LNmdj308hkGHtkId7M4YFns4mk/yfB4a48602eDI+mlNznu0FMPFd0wYKCaewl8F0EOueqM+yKSLVfJrNKWnSw/FZGzEGXFG9sy/px4gEBW1");
public static void load(){
basicShard = Schematics.readBase64("bXNjaAF4nGNgZmBmZmDJS8xNZZDJKCkpKLbS16/MLy0p1UtK1XcNi/Q3cKwwyqkyYOBOSS1OLsosKMnMz2NgYGDLSUxKzSlmYIqOZWTgSs4vStUtzkgsSgFKMYIQkAAAhSEXTA==");
basicFoundation = Schematics.readBase64("bXNjaAF4nGNgYWBhZmDJS8xNZWBNSk3MK2bgTkktTi7KLCjJzM9jYGBgy0lMSs0pZmCKjmVk4E/OL0rVTcsvzUtJhMozghCQAACx6RHB");
basicNucleus = Schematics.readBase64("bXNjaAF4nA3CwQ2AIBAEwAXFjxRBA1ZkfCDcgwh3BiTG7iUzMDATZvaFYGOK7pPuLpYXa6QWarqfJAxVsGR/Um7Q+6Fgg1TauIdMvQFQgB7wAza8E4M=");
basicBastion = Schematics.readBase64("bXNjaAF4nGNgYWBhZmDJS8xNZWBNzMsEUtwpqcXJRZkFJZn5eQyClfmlCin5Cnn5JQqpFZnFJVwMbDmJSak5xQxM0bGMDDzJ+UWpukmJxWDVDAyMIAQkACMdFqE=");
}
}
+167 -12
View File
@@ -1,25 +1,35 @@
package mindustry.content;
import arc.func.*;
import arc.graphics.*;
import mindustry.ctype.*;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.game.*;
import mindustry.graphics.*;
import mindustry.graphics.g3d.*;
import mindustry.graphics.g3d.PlanetGrid.*;
import mindustry.maps.planet.*;
import mindustry.type.*;
import mindustry.world.*;
import mindustry.world.meta.*;
public class Planets implements ContentList{
public class Planets{
public static Planet
sun,
//tantros,
serpulo;
erekir,
tantros,
serpulo,
gier,
notva,
verilus;
@Override
public void load(){
sun = new Planet("sun", null, 0, 2){{
public static void load(){
sun = new Planet("sun", null, 4f){{
bloom = true;
accessible = false;
//lightColor = Color.valueOf("f4ee8e");
meshLoader = () -> new SunMesh(
this, 4,
5, 0.3, 1.7, 1.2, 1,
@@ -33,22 +43,167 @@ public class Planets implements ContentList{
);
}};
/*tantros = new Planet("tantros", sun, 2, 0.8f){{
erekir = new Planet("erekir", sun, 1f, 2){{
generator = new ErekirPlanetGenerator();
meshLoader = () -> new HexMesh(this, 5);
cloudMeshLoader = () -> new MultiMesh(
new HexSkyMesh(this, 2, 0.15f, 0.14f, 5, Color.valueOf("eba768").a(0.75f), 2, 0.42f, 1f, 0.43f),
new HexSkyMesh(this, 3, 0.6f, 0.15f, 5, Color.valueOf("eea293").a(0.75f), 2, 0.42f, 1.2f, 0.45f)
);
alwaysUnlocked = true;
landCloudColor = Color.valueOf("ed6542");
atmosphereColor = Color.valueOf("f07218");
defaultEnv = Env.scorching | Env.terrestrial;
startSector = 10;
atmosphereRadIn = 0.02f;
atmosphereRadOut = 0.3f;
tidalLock = true;
orbitSpacing = 2f;
totalRadius += 2.6f;
lightSrcTo = 0.5f;
lightDstFrom = 0.2f;
clearSectorOnLose = true;
defaultCore = Blocks.coreBastion;
iconColor = Color.valueOf("ff9266");
enemyBuildSpeedMultiplier = 0.4f;
//TODO disallowed for now
allowLaunchToNumbered = false;
//TODO SHOULD there be lighting?
updateLighting = false;
defaultAttributes.set(Attribute.heat, 0.8f);
ruleSetter = r -> {
r.waveTeam = Team.malis;
r.placeRangeCheck = false;
r.showSpawns = true;
r.fog = true;
r.staticFog = true;
r.lighting = false;
r.coreDestroyClear = true;
r.onlyDepositCore = true;
};
campaignRuleDefaults.fog = true;
campaignRuleDefaults.showSpawns = true;
unlockedOnLand.add(Blocks.coreBastion);
}};
//TODO names
gier = makeAsteroid("gier", erekir, Blocks.ferricStoneWall, Blocks.carbonWall, -5, 0.4f, 7, 1f, gen -> {
gen.min = 25;
gen.max = 35;
gen.carbonChance = 0.6f;
gen.iceChance = 0f;
gen.berylChance = 0.1f;
});
notva = makeAsteroid("notva", sun, Blocks.ferricStoneWall, Blocks.beryllicStoneWall, -4, 0.55f, 9, 1.3f, gen -> {
gen.berylChance = 0.8f;
gen.iceChance = 0f;
gen.carbonChance = 0.01f;
gen.max += 2;
});
tantros = new Planet("tantros", sun, 1f, 2){{
generator = new TantrosPlanetGenerator();
meshLoader = () -> new HexMesh(this, 4);
accessible = false;
visible = false;
atmosphereColor = Color.valueOf("3db899");
iconColor = Color.valueOf("597be3");
startSector = 10;
atmosphereRadIn = -0.01f;
atmosphereRadOut = 0.3f;
}};*/
defaultEnv = Env.underwater | Env.terrestrial;
ruleSetter = r -> {
serpulo = new Planet("serpulo", sun, 3, 1){{
};
}};
serpulo = new Planet("serpulo", sun, 1f, 3){{
generator = new SerpuloPlanetGenerator();
meshLoader = () -> new HexMesh(this, 6);
cloudMeshLoader = () -> new MultiMesh(
new HexSkyMesh(this, 11, 0.15f, 0.13f, 5, new Color().set(Pal.spore).mul(0.9f).a(0.75f), 2, 0.45f, 0.9f, 0.38f),
new HexSkyMesh(this, 1, 0.6f, 0.16f, 5, Color.white.cpy().lerp(Pal.spore, 0.55f).a(0.75f), 2, 0.45f, 1f, 0.41f)
);
launchCapacityMultiplier = 0.5f;
sectorSeed = 2;
allowWaves = true;
allowLegacyLaunchPads = true;
allowWaveSimulation = true;
allowSectorInvasion = true;
allowLaunchSchematics = true;
enemyCoreSpawnReplace = true;
allowLaunchLoadout = true;
//doesn't play well with configs
prebuildBase = false;
ruleSetter = r -> {
r.waveTeam = Team.crux;
r.placeRangeCheck = false;
r.showSpawns = false;
r.coreDestroyClear = true;
};
iconColor = Color.valueOf("7d4dff");
atmosphereColor = Color.valueOf("3c1b8f");
atmosphereRadIn = 0.02f;
atmosphereRadOut = 0.3f;
startSector = 15;
alwaysUnlocked = true;
allowSelfSectorLaunch = true;
landCloudColor = Pal.spore.cpy().a(0.5f);
}};
verilus = makeAsteroid("verlius", sun, Blocks.stoneWall, Blocks.iceWall, -1, 0.5f, 12, 2f, gen -> {
gen.berylChance = 0f;
gen.iceChance = 0.6f;
gen.carbonChance = 0.1f;
gen.ferricChance = 0f;
});
}
private static Planet makeAsteroid(String name, Planet parent, Block base, Block tint, int seed, float tintThresh, int pieces, float scale, Cons<AsteroidGenerator> cgen){
return new Planet(name, parent, 0.12f){{
hasAtmosphere = false;
updateLighting = false;
sectors.add(new Sector(this, Ptile.empty));
camRadius = 0.68f * scale;
minZoom = 0.6f;
drawOrbit = false;
accessible = false;
clipRadius = 2f;
defaultEnv = Env.space;
icon = "commandRally";
generator = new AsteroidGenerator();
cgen.get((AsteroidGenerator)generator);
meshLoader = () -> {
iconColor = tint.mapColor;
Color tinted = tint.mapColor.cpy().a(1f - tint.mapColor.a);
Seq<GenericMesh> meshes = new Seq<>();
Color color = base.mapColor;
Rand rand = new Rand(id + 2);
meshes.add(new NoiseMesh(
this, seed, 2, radius, 2, 0.55f, 0.45f, 14f,
color, tinted, 3, 0.6f, 0.38f, tintThresh
));
for(int j = 0; j < pieces; j++){
meshes.add(new MatMesh(
new NoiseMesh(this, seed + j + 1, 1, 0.022f + rand.random(0.039f) * scale, 2, 0.6f, 0.38f, 20f,
color, tinted, 3, 0.6f, 0.38f, tintThresh),
new Mat3D().setToTranslation(Tmp.v31.setToRandomDirection(rand).setLength(rand.random(0.44f, 1.4f) * scale)))
);
}
return new MultiMesh(meshes.toArray(GenericMesh.class));
};
}};
}
}
+159 -11
View File
@@ -1,30 +1,42 @@
package mindustry.content;
import mindustry.ctype.*;
import mindustry.type.*;
import static mindustry.content.Planets.*;
public class SectorPresets implements ContentList{
public class SectorPresets{
public static SectorPreset
groundZero,
craters, biomassFacility, frozenForest, ruinousShores, windsweptIslands, stainedMountains, tarFields,
fungalPass, extractionOutpost, saltFlats, overgrowth,
impact0078, desolateRift, nuclearComplex, planetaryTerminal;
craters, biomassFacility, taintedWoods, frozenForest, ruinousShores, facility32m, windsweptIslands, stainedMountains, tarFields,
frontier, fungalPass, infestedCanyons, atolls, mycelialBastion, extractionOutpost, saltFlats, testingGrounds, overgrowth, //polarAerodrome,
impact0078, desolateRift, nuclearComplex, planetaryTerminal,
coastline, navalFortress, weatheredChannels, seaPort,
@Override
public void load(){
geothermalStronghold, cruxscape,
onset, aegis, lake, intersect, basin, atlas, split, marsh, peaks, ravine, caldera,
stronghold, crevice, siege, crossroads, karst, origin;
public static void load(){
//region serpulo
groundZero = new SectorPreset("groundZero", serpulo, 15){{
alwaysUnlocked = true;
addStartingItems = true;
captureWave = 10;
difficulty = 1;
overrideLaunchDefaults = true;
noLighting = true;
startWaveTimeMultiplier = 3f;
}};
saltFlats = new SectorPreset("saltFlats", serpulo, 101){{
difficulty = 5;
useAI = false;
}};
testingGrounds = new SectorPreset("testingGrounds", serpulo, 3){{
difficulty = 7;
captureWave = 33;
}};
frozenForest = new SectorPreset("frozenForest", serpulo, 86){{
@@ -37,6 +49,11 @@ public class SectorPresets implements ContentList{
difficulty = 3;
}};
taintedWoods = new SectorPreset("taintedWoods", serpulo, 221){{
captureWave = 33;
difficulty = 5;
}};
craters = new SectorPreset("craters", serpulo, 18){{
captureWave = 20;
difficulty = 2;
@@ -47,6 +64,15 @@ public class SectorPresets implements ContentList{
difficulty = 3;
}};
seaPort = new SectorPreset("seaPort", serpulo, 47){{
difficulty = 4;
}};
facility32m = new SectorPreset("facility32m", serpulo, 64){{
captureWave = 25;
difficulty = 4;
}};
windsweptIslands = new SectorPreset("windsweptIslands", serpulo, 246){{
captureWave = 30;
difficulty = 4;
@@ -59,17 +85,49 @@ public class SectorPresets implements ContentList{
extractionOutpost = new SectorPreset("extractionOutpost", serpulo, 165){{
difficulty = 5;
useAI = false;
}};
//TODO: removed for now
//polarAerodrome = new SectorPreset("polarAerodrome", serpulo, 68){{
// difficulty = 7;
//}};
coastline = new SectorPreset("coastline", serpulo, 108){{
captureWave = 30;
difficulty = 5;
}};
weatheredChannels = new SectorPreset("weatheredChannels", serpulo, 39){{
captureWave = 40;
difficulty = 9;
}};
navalFortress = new SectorPreset("navalFortress", serpulo, 216){{
difficulty = 8;
}};
frontier = new SectorPreset("frontier", serpulo, 203){{
difficulty = 4;
}};
fungalPass = new SectorPreset("fungalPass", serpulo, 21){{
difficulty = 4;
useAI = false;
}};
infestedCanyons = new SectorPreset("infestedCanyons", serpulo, 210){{
difficulty = 4;
}};
atolls = new SectorPreset("atolls", serpulo, 1){{
difficulty = 7;
}};
mycelialBastion = new SectorPreset("mycelialBastion", serpulo, 260){{
difficulty = 8;
}};
overgrowth = new SectorPreset("overgrowth", serpulo, 134){{
difficulty = 5;
useAI = false;
}};
tarFields = new SectorPreset("tarFields", serpulo, 23){{
@@ -94,6 +152,96 @@ public class SectorPresets implements ContentList{
planetaryTerminal = new SectorPreset("planetaryTerminal", serpulo, 93){{
difficulty = 10;
isLastSector = true;
}};
geothermalStronghold = new SectorPreset("geothermalStronghold", serpulo, 264){{
difficulty = 10;
}};
cruxscape = new SectorPreset("cruxscape", serpulo, 54){{
difficulty = 10;
}};
//endregion
//region erekir
onset = new SectorPreset("onset", erekir, 10){{
addStartingItems = true;
alwaysUnlocked = true;
difficulty = 1;
}};
aegis = new SectorPreset("aegis", erekir, 88){{
difficulty = 3;
}};
lake = new SectorPreset("lake", erekir, 41){{
difficulty = 4;
}};
intersect = new SectorPreset("intersect", erekir, 36){{
difficulty = 5;
captureWave = 9;
attackAfterWaves = true;
}};
atlas = new SectorPreset("atlas", erekir, 14){{ //TODO random sector, pick a better one
difficulty = 5;
}};
split = new SectorPreset("split", erekir, 19){{ //TODO random sector, pick a better one
difficulty = 2;
}};
basin = new SectorPreset("basin", erekir, 29){{
difficulty = 6;
}};
marsh = new SectorPreset("marsh", erekir, 25){{
difficulty = 4;
}};
peaks = new SectorPreset("peaks", erekir, 30){{
difficulty = 3;
}};
ravine = new SectorPreset("ravine", erekir, 39){{
difficulty = 4;
captureWave = 24;
}};
caldera = new SectorPreset("caldera-erekir", erekir, 43){{
difficulty = 4;
}};
stronghold = new SectorPreset("stronghold", erekir, 18){{
difficulty = 7;
}};
crevice = new SectorPreset("crevice", erekir, 3){{
difficulty = 6;
captureWave = 46;
}};
siege = new SectorPreset("siege", erekir, 58){{
difficulty = 8;
}};
crossroads = new SectorPreset("crossroads", erekir, 37){{
difficulty = 7;
}};
karst = new SectorPreset("karst", erekir, 5){{
difficulty = 9;
captureWave = 10;
}};
origin = new SectorPreset("origin", erekir, 12){{
difficulty = 10;
isLastSector = true;
}};
//endregion
}
}
@@ -0,0 +1,780 @@
package mindustry.content;
import arc.struct.*;
import mindustry.game.Objectives.*;
import mindustry.type.*;
import static mindustry.content.Blocks.*;
import static mindustry.content.SectorPresets.craters;
import static mindustry.content.SectorPresets.*;
import static mindustry.content.TechTree.*;
import static mindustry.content.UnitTypes.*;
public class SerpuloTechTree{
public static void load(){
Planets.serpulo.techTree = nodeRoot("serpulo", coreShard, () -> {
node(conveyor, () -> {
node(junction, () -> {
node(router, () -> {
node(advancedLaunchPad, Seq.with(new SectorComplete(extractionOutpost)), () -> {
node(landingPad, () -> {
node(interplanetaryAccelerator, Seq.with(new SectorComplete(planetaryTerminal)), () -> {
});
});
});
node(distributor);
node(sorter, () -> {
node(invertedSorter);
node(overflowGate, () -> {
node(underflowGate);
});
});
node(container, Seq.with(new SectorComplete(biomassFacility)), () -> {
node(unloader);
node(vault, Seq.with(new SectorComplete(stainedMountains)), () -> {
});
});
node(itemBridge, () -> {
node(titaniumConveyor, Seq.with(new SectorComplete(craters)), () -> {
node(phaseConveyor, () -> {
node(massDriver, () -> {
});
});
node(payloadConveyor, () -> {
node(payloadRouter, () -> {
});
});
node(armoredConveyor, () -> {
node(plastaniumConveyor, () -> {
});
});
});
});
});
});
});
node(coreFoundation, () -> {
node(coreNucleus, () -> {
});
});
node(mechanicalDrill, () -> {
node(mechanicalPump, () -> {
node(conduit, () -> {
node(liquidJunction, () -> {
node(liquidRouter, () -> {
node(liquidContainer, () -> {
node(liquidTank);
});
node(bridgeConduit);
node(pulseConduit, Seq.with(new SectorComplete(windsweptIslands)), () -> {
node(phaseConduit, () -> {
});
node(platedConduit, () -> {
});
node(rotaryPump, () -> {
node(impulsePump, () -> {
});
});
});
});
});
});
});
node(graphitePress, () -> {
node(pneumaticDrill, Seq.with(new SectorComplete(frozenForest)), () -> {
node(cultivator, Seq.with(new SectorComplete(biomassFacility)), () -> {
});
node(laserDrill, () -> {
node(blastDrill, Seq.with(new SectorComplete(nuclearComplex)), () -> {
});
node(waterExtractor, Seq.with(new SectorComplete(saltFlats)), () -> {
node(oilExtractor, () -> {
});
});
});
});
node(pyratiteMixer, () -> {
node(blastMixer, Seq.with(new SectorComplete(facility32m)), () -> {
});
});
node(siliconSmelter, () -> {
node(sporePress, () -> {
node(coalCentrifuge, () -> {
node(multiPress, () -> {
node(siliconCrucible, () -> {
});
});
});
node(plastaniumCompressor, Seq.with(new SectorComplete(windsweptIslands), new OnSector(tarFields)), () -> {
node(phaseWeaver, Seq.with(new SectorComplete(tarFields)), () -> {
});
});
});
node(kiln, Seq.with(new SectorComplete(craters)), () -> {
node(pulverizer, () -> {
node(incinerator, () -> {
node(melter, () -> {
node(surgeSmelter, () -> {
});
node(separator, () -> {
node(disassembler, () -> {
});
});
node(cryofluidMixer, () -> {
});
});
});
});
});
//logic disabled until further notice
node(microProcessor, () -> {
node(switchBlock, () -> {
node(message, () -> {
node(logicDisplay, () -> {
node(largeLogicDisplay, () -> {
});
});
node(memoryCell, () -> {
node(memoryBank, () -> {
});
});
});
node(logicProcessor, () -> {
node(hyperProcessor, () -> {
});
});
});
});
node(illuminator, () -> {
});
});
});
node(combustionGenerator, Seq.with(new Research(Items.coal)), () -> {
node(powerNode, () -> {
node(powerNodeLarge, () -> {
node(diode, () -> {
node(surgeTower, () -> {
});
});
});
node(battery, () -> {
node(batteryLarge, () -> {
});
});
node(mender, () -> {
node(mendProjector, () -> {
node(forceProjector, Seq.with(new SectorComplete(impact0078)), () -> {
node(overdriveProjector, Seq.with(new SectorComplete(impact0078)), () -> {
node(overdriveDome, Seq.with(new SectorComplete(impact0078)), () -> {
});
});
});
node(repairPoint, () -> {
node(repairTurret, () -> {
});
});
});
});
node(steamGenerator, Seq.with(new SectorComplete(craters)), () -> {
node(thermalGenerator, () -> {
node(differentialGenerator, () -> {
node(thoriumReactor, Seq.with(new Research(Liquids.cryofluid)), () -> {
node(impactReactor, () -> {
});
node(rtgGenerator, () -> {
});
});
});
});
});
node(solarPanel, () -> {
node(largeSolarPanel, () -> {
});
});
});
});
});
node(duo, () -> {
node(copperWall, () -> {
node(copperWallLarge, () -> {
node(scrapWall, () -> {
node(scrapWallLarge, () -> {
node(scrapWallHuge, () -> {
node(scrapWallGigantic);
});
});
});
node(titaniumWall, () -> {
node(titaniumWallLarge);
node(door, () -> {
node(doorLarge);
});
node(plastaniumWall, () -> {
node(plastaniumWallLarge, () -> {
});
});
node(thoriumWall, () -> {
node(thoriumWallLarge);
node(surgeWall, () -> {
node(surgeWallLarge);
node(phaseWall, () -> {
node(phaseWallLarge);
});
});
});
});
});
});
node(scatter, () -> {
node(hail, Seq.with(new SectorComplete(craters)), () -> {
node(salvo, () -> {
node(swarmer, () -> {
node(cyclone, () -> {
node(spectre, Seq.with(new SectorComplete(nuclearComplex)), () -> {
});
});
});
node(ripple, () -> {
node(fuse, () -> {
});
});
});
});
});
node(scorch, () -> {
node(arc, () -> {
node(wave, () -> {
node(parallax, () -> {
node(segment, () -> {
});
});
node(tsunami, () -> {
});
});
node(lancer, () -> {
node(meltdown, () -> {
node(foreshadow, () -> {
});
});
node(shockMine, () -> {
});
});
});
});
});
node(groundFactory, () -> {
node(dagger, () -> {
node(mace, () -> {
node(fortress, () -> {
node(scepter, () -> {
node(reign, () -> {
});
});
});
});
node(nova, () -> {
node(pulsar, () -> {
node(quasar, () -> {
node(vela, () -> {
node(corvus, () -> {
});
});
});
});
});
//override research requirements to have graphite, not coal
node(crawler, ItemStack.with(Items.silicon, 400, Items.graphite, 400), () -> {
node(atrax, () -> {
node(spiroct, () -> {
node(arkyid, () -> {
node(toxopid, Seq.with(new SectorComplete(mycelialBastion)), () -> {
});
});
});
});
});
});
node(airFactory, () -> {
node(flare, () -> {
node(horizon, () -> {
node(zenith, () -> {
node(antumbra, () -> {
node(eclipse, () -> {
});
});
});
});
node(mono, () -> {
node(poly, () -> {
node(mega, () -> {
node(quad, () -> {
node(oct, () -> {
});
});
});
});
});
});
node(navalFactory, Seq.with(new OnSector(windsweptIslands)), () -> {
node(risso, () -> {
node(minke, () -> {
node(bryde, () -> {
node(sei, () -> {
node(omura, () -> {
});
});
});
});
node(retusa, Seq.with(new SectorComplete(windsweptIslands)), () -> {
node(oxynoe, Seq.with(new SectorComplete(coastline)), () -> {
node(cyerce, () -> {
node(aegires, () -> {
node(navanax, Seq.with(new SectorComplete(navalFortress)), () -> {
});
});
});
});
});
});
});
});
node(additiveReconstructor, Seq.with(new SectorComplete(biomassFacility)), () -> {
node(multiplicativeReconstructor, Seq.with(new SectorComplete(overgrowth)), () -> {
node(exponentialReconstructor, () -> {
node(tetrativeReconstructor, () -> {
});
});
});
});
});
node(groundZero, () -> {
node(frozenForest, Seq.with(
new SectorComplete(groundZero),
new Research(junction),
new Research(router)
), () -> {
node(craters, Seq.with(
new SectorComplete(frozenForest),
new Research(mender),
new Research(combustionGenerator)
), () -> {
node(frontier, Seq.with(
new Research(groundFactory),
new Research(airFactory),
new Research(thermalGenerator),
new Research(dagger),
new Research(mono)
), () -> {
});
node(ruinousShores, Seq.with(
new SectorComplete(craters),
new Research(graphitePress),
new Research(kiln),
new Research(mechanicalPump)
), () -> {
node(windsweptIslands, Seq.with(
new SectorComplete(ruinousShores),
new Research(pneumaticDrill),
new Research(hail),
new Research(siliconSmelter),
new Research(steamGenerator)
), () -> {
node(seaPort, Seq.with(
new SectorComplete(biomassFacility),
new Research(navalFactory),
new Research(risso),
new Research(retusa),
new Research(steamGenerator),
new Research(cultivator),
new Research(coalCentrifuge)
), () -> {
});
node(tarFields, Seq.with(
new SectorComplete(windsweptIslands),
new Research(coalCentrifuge),
new Research(conduit),
new Research(wave)
), () -> {
node(impact0078, Seq.with(
new SectorComplete(tarFields),
new Research(Items.thorium),
new Research(lancer),
new Research(salvo),
new Research(coreFoundation)
), () -> {
node(desolateRift, Seq.with(
new SectorComplete(impact0078),
new Research(thermalGenerator),
new Research(thoriumReactor),
new Research(coreNucleus)
), () -> {
node(planetaryTerminal, Seq.with(
new SectorComplete(desolateRift),
new SectorComplete(nuclearComplex),
new SectorComplete(overgrowth),
new SectorComplete(extractionOutpost),
new SectorComplete(saltFlats),
new Research(risso),
new Research(minke),
new Research(bryde),
new Research(sei),
new Research(omura),
new Research(spectre),
new Research(advancedLaunchPad),
new Research(massDriver),
new Research(impactReactor),
new Research(additiveReconstructor),
new Research(exponentialReconstructor),
new Research(tetrativeReconstructor)
), () -> {
node(geothermalStronghold, Seq.with(
new Research(omura),
new Research(navanax),
new Research(eclipse),
new Research(oct),
new Research(reign),
new Research(corvus),
new Research(toxopid)
), () -> {
});
node(cruxscape, Seq.with(
new Research(omura),
new Research(navanax),
new Research(eclipse),
new Research(oct),
new Research(reign),
new Research(corvus),
new Research(toxopid)
), () -> {
});
});
});
});
});
node(facility32m, Seq.with(
new Research(pneumaticDrill),
new SectorComplete(stainedMountains)
), () -> {
node(extractionOutpost, Seq.with(
new SectorComplete(windsweptIslands),
new SectorComplete(facility32m),
new Research(groundFactory),
new Research(nova),
new Research(airFactory),
new Research(mono)
), () -> {
//TODO: removed for now
/*node(polarAerodrome, Seq.with(
new SectorComplete(fungalPass),
new SectorComplete(desolateRift),
new SectorComplete(overgrowth),
new Research(multiplicativeReconstructor),
new Research(zenith),
new Research(swarmer),
new Research(cyclone),
new Research(blastDrill),
new Research(blastDrill),
new Research(massDriver)
), () -> {
});
*/
});
});
node(saltFlats, Seq.with(
new SectorComplete(windsweptIslands),
new Research(groundFactory),
new Research(additiveReconstructor),
new Research(airFactory),
new Research(door)
), () -> {
node(testingGrounds, Seq.with(
new Research(cryofluidMixer),
new Research(Liquids.cryofluid),
new Research(waterExtractor),
new Research(ripple)
), () -> {
});
node(coastline, Seq.with(
new SectorComplete(windsweptIslands),
new SectorComplete(saltFlats),
new Research(navalFactory),
new Research(payloadConveyor)
), () -> {
node(navalFortress, Seq.with(
new SectorComplete(coastline),
new SectorComplete(extractionOutpost),
new Research(coreNucleus),
new Research(massDriver),
new Research(oxynoe),
new Research(minke),
new Research(bryde),
new Research(cyclone),
new Research(ripple)
), () -> {
node(weatheredChannels, Seq.with(
new SectorComplete(impact0078),
new Research(bryde),
new Research(surgeSmelter),
new Research(overdriveProjector)
), () -> {
});
});
});
});
});
});
node(overgrowth, Seq.with(
new SectorComplete(craters),
new SectorComplete(fungalPass),
new Research(cultivator),
new Research(sporePress),
new Research(additiveReconstructor),
new Research(UnitTypes.mace),
new Research(UnitTypes.flare)
), () -> {
node(mycelialBastion, Seq.with(
new Research(atrax),
new Research(spiroct),
new Research(multiplicativeReconstructor),
new Research(exponentialReconstructor)
), () -> {
});
node(atolls, Seq.with(
new SectorComplete(windsweptIslands),
new Research(multiplicativeReconstructor),
new Research(mega)
), () -> {
});
});
});
node(biomassFacility, Seq.with(
new SectorComplete(frozenForest),
new Research(powerNode),
new Research(steamGenerator),
new Research(scatter),
new Research(graphitePress)
), () -> {
node(taintedWoods, Seq.with(
new SectorComplete(biomassFacility),
new Research(Items.sporePod),
new Research(wave)
), () -> {
});
node(stainedMountains, Seq.with(
new SectorComplete(biomassFacility),
new Research(pneumaticDrill),
new Research(siliconSmelter)
), () -> {
node(fungalPass, Seq.with(
new SectorComplete(stainedMountains),
new Research(groundFactory),
new Research(door)
), () -> {
node(infestedCanyons, Seq.with(
new SectorComplete(fungalPass),
new Research(navalFactory),
new Research(risso),
new Research(minke),
new Research(additiveReconstructor)
), () -> {
});
node(nuclearComplex, Seq.with(
new SectorComplete(fungalPass),
new Research(thermalGenerator),
new Research(laserDrill),
new Research(Items.plastanium),
new Research(swarmer)
), () -> {
});
});
});
});
});
});
nodeProduce(Items.copper, () -> {
nodeProduce(Liquids.water, () -> {
});
nodeProduce(Items.lead, () -> {
nodeProduce(Items.titanium, () -> {
nodeProduce(Liquids.cryofluid, () -> {
});
nodeProduce(Items.thorium, () -> {
nodeProduce(Items.surgeAlloy, () -> {
});
nodeProduce(Items.phaseFabric, () -> {
});
});
});
nodeProduce(Items.metaglass, () -> {
});
});
nodeProduce(Items.sand, () -> {
nodeProduce(Items.scrap, () -> {
nodeProduce(Liquids.slag, () -> {
});
});
nodeProduce(Items.coal, () -> {
nodeProduce(Items.graphite, () -> {
nodeProduce(Items.silicon, () -> {
});
});
nodeProduce(Items.pyratite, () -> {
nodeProduce(Items.blastCompound, () -> {
});
});
nodeProduce(Items.sporePod, () -> {
});
nodeProduce(Liquids.oil, () -> {
nodeProduce(Items.plastanium, () -> {
});
});
});
});
});
});
}
}
+68 -30
View File
@@ -3,34 +3,33 @@ package mindustry.content;
import arc.*;
import arc.graphics.*;
import arc.math.*;
import mindustry.ctype.*;
import mindustry.game.EventType.*;
import mindustry.type.*;
import mindustry.game.*;
import mindustry.graphics.*;
import mindustry.type.*;
import static mindustry.Vars.*;
public class StatusEffects implements ContentList{
public static StatusEffect none, burning, freezing, unmoving, slow, wet, muddy, melting, sapped, tarred, overdrive, overclock, shielded, shocked, blasted, corroded, boss, sporeSlowed;
public class StatusEffects{
public static StatusEffect none, burning, freezing, unmoving, slow, fast, wet, muddy, melting, sapped, tarred, overdrive, overclock, shielded, shocked, blasted, corroded, boss, sporeSlowed, disarmed, electrified, invincible, dynamic;
@Override
public void load(){
public static void load(){
none = new StatusEffect("none");
burning = new StatusEffect("burning"){{
color = Pal.lightFlame;
damage = 0.12f; //over 8 seconds, this would be 60 damage
color = Color.valueOf("ffc455");
damage = 0.167f;
effect = Fx.burning;
transitionDamage = 8f;
init(() -> {
opposite(wet, freezing);
trans(tarred, ((unit, time, newTime, result) -> {
unit.damagePierce(8f);
affinity(tarred, (unit, result, time) -> {
unit.damagePierce(transitionDamage);
Fx.burning.at(unit.x + Mathf.range(unit.bounds() / 2f), unit.y + Mathf.range(unit.bounds() / 2f));
result.set(this, Math.min(time + newTime, 300f));
}));
result.set(burning, Math.min(time + result.time, 300f));
});
});
}};
@@ -39,25 +38,37 @@ public class StatusEffects implements ContentList{
speedMultiplier = 0.6f;
healthMultiplier = 0.8f;
effect = Fx.freezing;
transitionDamage = 18f;
init(() -> {
opposite(melting, burning);
trans(blasted, ((unit, time, newTime, result) -> {
unit.damagePierce(18f);
result.set(this, time);
}));
affinity(blasted, (unit, result, time) -> {
unit.damagePierce(transitionDamage);
if(unit.team == state.rules.waveTeam){
Events.fire(Trigger.blastFreeze);
}
});
});
}};
unmoving = new StatusEffect("unmoving"){{
color = Pal.gray;
speedMultiplier = 0.001f;
speedMultiplier = 0f;
}};
slow = new StatusEffect("slow"){{
color = Pal.lightishGray;
speedMultiplier = 0.4f;
init(() -> opposite(fast));
}};
fast = new StatusEffect("fast"){{
color = Pal.boostTo;
speedMultiplier = 1.6f;
init(() -> opposite(slow));
}};
wet = new StatusEffect("wet"){{
@@ -65,24 +76,26 @@ public class StatusEffects implements ContentList{
speedMultiplier = 0.94f;
effect = Fx.wet;
effectChance = 0.09f;
transitionDamage = 14;
init(() -> {
trans(shocked, ((unit, time, newTime, result) -> {
unit.damagePierce(14f);
affinity(shocked, (unit, result, time) -> {
unit.damage(transitionDamage);
if(unit.team == state.rules.waveTeam){
Events.fire(Trigger.shock);
}
result.set(this, time);
}));
opposite(burning);
});
opposite(burning, melting);
});
}};
muddy = new StatusEffect("muddy"){{
color = Color.valueOf("46382a");
speedMultiplier = 0.94f;
effect = Fx.muddy;
effectChance = 0.09f;
show = false;
}};
melting = new StatusEffect("melting"){{
@@ -94,11 +107,11 @@ public class StatusEffects implements ContentList{
init(() -> {
opposite(wet, freezing);
trans(tarred, ((unit, time, newTime, result) -> {
affinity(tarred, (unit, result, time) -> {
unit.damagePierce(8f);
Fx.burning.at(unit.x + Mathf.range(unit.bounds() / 2f), unit.y + Mathf.range(unit.bounds() / 2f));
result.set(this, Math.min(time + newTime, 200f));
}));
result.set(melting, Math.min(time + result.time, 200f));
});
});
}};
@@ -110,6 +123,14 @@ public class StatusEffects implements ContentList{
effectChance = 0.1f;
}};
electrified = new StatusEffect("electrified"){{
color = Pal.heal;
speedMultiplier = 0.7f;
reloadMultiplier = 0.6f;
effect = Fx.electrified;
effectChance = 0.1f;
}};
sporeSlowed = new StatusEffect("spore-slowed"){{
color = Pal.spore;
speedMultiplier = 0.8f;
@@ -123,8 +144,8 @@ public class StatusEffects implements ContentList{
effect = Fx.oily;
init(() -> {
trans(melting, ((unit, time, newTime, result) -> result.set(melting, newTime + time)));
trans(burning, ((unit, time, newTime, result) -> result.set(burning, newTime + time)));
affinity(melting, (unit, result, time) -> result.set(melting, result.time + time));
affinity(burning, (unit, result, time) -> result.set(burning, result.time + time));
});
}};
@@ -153,7 +174,7 @@ public class StatusEffects implements ContentList{
}};
boss = new StatusEffect("boss"){{
color = Pal.health;
color = Team.crux.color;
permanent = true;
damageMultiplier = 1.3f;
healthMultiplier = 1.5f;
@@ -161,15 +182,32 @@ public class StatusEffects implements ContentList{
shocked = new StatusEffect("shocked"){{
color = Pal.lancerLaser;
reactive = true;
}};
blasted = new StatusEffect("blasted"){{
color = Color.valueOf("ff795e");
reactive = true;
}};
corroded = new StatusEffect("corroded"){{
color = Pal.plastanium;
damage = 0.1f;
}};
disarmed = new StatusEffect("disarmed"){{
color = Color.valueOf("e9ead3");
disarm = true;
}};
invincible = new StatusEffect("invincible"){{
healthMultiplier = Float.POSITIVE_INFINITY;
}};
dynamic = new StatusEffect("dynamic"){{
show = false;
dynamic = true;
permanent = true;
}};
}
}
@@ -0,0 +1,14 @@
package mindustry.content;
public class TeamEntries{
public static void load(){
//more will be added later - do these need references?
//TODO
//new TeamEntry(Team.derelict);
//new TeamEntry(Team.sharded);
//new TeamEntry(Team.malis);
//new TeamEntry(Team.crux);
}
}
+88 -648
View File
@@ -1,652 +1,42 @@
package mindustry.content;
import arc.*;
import arc.func.*;
import arc.scene.style.*;
import arc.struct.*;
import arc.util.*;
import mindustry.ctype.*;
import mindustry.game.Objectives.*;
import mindustry.type.*;
import static mindustry.content.Blocks.*;
import static mindustry.content.SectorPresets.craters;
import static mindustry.content.SectorPresets.*;
import static mindustry.content.UnitTypes.*;
/** Class for storing a list of TechNodes with some utility tree builder methods; context dependent. See {@link SerpuloTechTree#load} source for example usage. */
public class TechTree{
private static TechNode context = null;
public class TechTree implements ContentList{
static ObjectMap<UnlockableContent, TechNode> map = new ObjectMap<>();
static TechNode context = null;
public static Seq<TechNode> all = new Seq<>();
public static Seq<TechNode> roots = new Seq<>();
public static Seq<TechNode> all;
public static TechNode root;
@Override
public void load(){
setup();
root = node(coreShard, () -> {
node(conveyor, () -> {
node(junction, () -> {
node(router, () -> {
node(launchPad, Seq.with(new SectorComplete(extractionOutpost)), () -> {
node(interplanetaryAccelerator, Seq.with(new SectorComplete(planetaryTerminal)), () -> {
});
});
node(distributor);
node(sorter, () -> {
node(invertedSorter);
node(overflowGate, () -> {
node(underflowGate);
});
});
node(container, Seq.with(new SectorComplete(biomassFacility)), () -> {
node(unloader);
node(vault, Seq.with(new SectorComplete(stainedMountains)), () -> {
});
});
node(itemBridge, () -> {
node(titaniumConveyor, Seq.with(new SectorComplete(craters)), () -> {
node(phaseConveyor, () -> {
node(massDriver, () -> {
});
});
node(payloadConveyor, () -> {
node(payloadRouter, () -> {
});
});
node(armoredConveyor, () -> {
node(plastaniumConveyor, () -> {
});
});
});
});
});
});
});
node(coreFoundation, () -> {
node(coreNucleus, () -> {
});
});
node(mechanicalDrill, () -> {
node(mechanicalPump, () -> {
node(conduit, () -> {
node(liquidJunction, () -> {
node(liquidRouter, () -> {
node(liquidTank);
node(bridgeConduit);
node(pulseConduit, Seq.with(new SectorComplete(windsweptIslands)), () -> {
node(phaseConduit, () -> {
});
node(platedConduit, () -> {
});
node(rotaryPump, () -> {
node(thermalPump, () -> {
});
});
});
});
});
});
});
node(graphitePress, () -> {
node(pneumaticDrill, Seq.with(new SectorComplete(frozenForest)), () -> {
node(cultivator, Seq.with(new SectorComplete(biomassFacility)), () -> {
});
node(laserDrill, () -> {
node(blastDrill, Seq.with(new SectorComplete(nuclearComplex)), () -> {
});
node(waterExtractor, Seq.with(new SectorComplete(saltFlats)), () -> {
node(oilExtractor, () -> {
});
});
});
});
node(pyratiteMixer, () -> {
node(blastMixer, () -> {
});
});
node(siliconSmelter, () -> {
node(sporePress, () -> {
node(coalCentrifuge, () -> {
node(multiPress, () -> {
node(siliconCrucible, () -> {
});
});
});
node(plastaniumCompressor, Seq.with(new SectorComplete(windsweptIslands)), () -> {
node(phaseWeaver, Seq.with(new SectorComplete(tarFields)), () -> {
});
});
});
node(kiln, Seq.with(new SectorComplete(craters)), () -> {
node(pulverizer, () -> {
node(incinerator, () -> {
node(melter, () -> {
node(surgeSmelter, () -> {
});
node(separator, () -> {
node(disassembler, () -> {
});
});
node(cryofluidMixer, () -> {
});
});
});
});
});
node(microProcessor, () -> {
node(switchBlock, () -> {
node(message, () -> {
node(logicDisplay, () -> {
node(largeLogicDisplay, () -> {
});
});
node(memoryCell, () -> {
node(memoryBank, () -> {
});
});
});
node(logicProcessor, () -> {
node(hyperProcessor, () -> {
});
});
});
});
node(illuminator, () -> {
});
});
});
node(combustionGenerator, Seq.with(new Research(Items.coal)), () -> {
node(powerNode, () -> {
node(powerNodeLarge, () -> {
node(diode, () -> {
node(surgeTower, () -> {
});
});
});
node(battery, () -> {
node(batteryLarge, () -> {
});
});
node(mender, () -> {
node(mendProjector, () -> {
node(forceProjector, Seq.with(new SectorComplete(impact0078)), () -> {
node(overdriveProjector, Seq.with(new SectorComplete(impact0078)), () -> {
node(overdriveDome, Seq.with(new SectorComplete(impact0078)), () -> {
});
});
});
node(repairPoint, () -> {
});
});
});
node(steamGenerator, Seq.with(new SectorComplete(craters)), () -> {
node(thermalGenerator, () -> {
node(differentialGenerator, () -> {
node(thoriumReactor, Seq.with(new Research(Liquids.cryofluid)), () -> {
node(impactReactor, () -> {
});
node(rtgGenerator, () -> {
});
});
});
});
});
node(solarPanel, () -> {
node(largeSolarPanel, () -> {
});
});
});
});
});
node(duo, () -> {
node(copperWall, () -> {
node(copperWallLarge, () -> {
node(titaniumWall, () -> {
node(titaniumWallLarge);
node(door, () -> {
node(doorLarge);
});
node(plastaniumWall, () -> {
node(plastaniumWallLarge, () -> {
});
});
node(thoriumWall, () -> {
node(thoriumWallLarge);
node(surgeWall, () -> {
node(surgeWallLarge);
node(phaseWall, () -> {
node(phaseWallLarge);
});
});
});
});
});
});
node(scatter, () -> {
node(hail, Seq.with(new SectorComplete(craters)), () -> {
node(salvo, () -> {
node(swarmer, () -> {
node(cyclone, () -> {
node(spectre, Seq.with(new SectorComplete(nuclearComplex)), () -> {
});
});
});
node(ripple, () -> {
node(fuse, () -> {
});
});
});
});
});
node(scorch, () -> {
node(arc, () -> {
node(wave, () -> {
node(parallax, () -> {
node(segment, () -> {
});
});
node(tsunami, () -> {
});
});
node(lancer, () -> {
node(meltdown, () -> {
node(foreshadow, () -> {
});
});
node(shockMine, () -> {
});
});
});
});
});
node(groundFactory, () -> {
node(commandCenter, () -> {
});
node(dagger, () -> {
node(mace, () -> {
node(fortress, () -> {
node(scepter, () -> {
node(reign, () -> {
});
});
});
});
node(nova, () -> {
node(pulsar, () -> {
node(quasar, () -> {
node(vela, () -> {
node(corvus, () -> {
});
});
});
});
});
node(crawler, () -> {
node(atrax, () -> {
node(spiroct, () -> {
node(arkyid, () -> {
node(toxopid, () -> {
});
});
});
});
});
});
node(airFactory, () -> {
node(flare, () -> {
node(horizon, () -> {
node(zenith, () -> {
node(antumbra, () -> {
node(eclipse, () -> {
});
});
});
});
node(mono, () -> {
node(poly, () -> {
node(mega, () -> {
node(quad, () -> {
node(oct, () -> {
});
});
});
});
});
});
node(navalFactory, Seq.with(new SectorComplete(ruinousShores)), () -> {
node(risso, () -> {
node(minke, () -> {
node(bryde, () -> {
node(sei, () -> {
node(omura, () -> {
});
});
});
});
});
});
});
node(additiveReconstructor, Seq.with(new SectorComplete(biomassFacility)), () -> {
node(multiplicativeReconstructor, () -> {
node(exponentialReconstructor, Seq.with(new SectorComplete(overgrowth)), () -> {
node(tetrativeReconstructor, () -> {
});
});
});
});
});
node(groundZero, () -> {
node(frozenForest, Seq.with(
new SectorComplete(groundZero),
new Research(junction),
new Research(router)
), () -> {
node(craters, Seq.with(
new SectorComplete(frozenForest),
new Research(mender),
new Research(combustionGenerator)
), () -> {
node(ruinousShores, Seq.with(
new SectorComplete(craters),
new Research(graphitePress),
new Research(combustionGenerator),
new Research(kiln),
new Research(mechanicalPump)
), () -> {
node(windsweptIslands, Seq.with(
new SectorComplete(ruinousShores),
new Research(pneumaticDrill),
new Research(hail),
new Research(siliconSmelter),
new Research(steamGenerator)
), () -> {
node(tarFields, Seq.with(
new SectorComplete(windsweptIslands),
new Research(coalCentrifuge),
new Research(conduit),
new Research(wave)
), () -> {
node(impact0078, Seq.with(
new SectorComplete(tarFields),
new Research(Items.thorium),
new Research(lancer),
new Research(salvo),
new Research(coreFoundation)
), () -> {
node(desolateRift, Seq.with(
new SectorComplete(impact0078),
new Research(thermalGenerator),
new Research(thoriumReactor),
new Research(coreNucleus)
), () -> {
node(planetaryTerminal, Seq.with(
new SectorComplete(desolateRift),
new SectorComplete(nuclearComplex),
new SectorComplete(overgrowth),
new SectorComplete(extractionOutpost),
new SectorComplete(saltFlats),
new Research(risso),
new Research(minke),
new Research(bryde),
new Research(spectre),
new Research(launchPad),
new Research(massDriver),
new Research(impactReactor),
new Research(additiveReconstructor),
new Research(exponentialReconstructor)
), () -> {
});
});
});
});
node(extractionOutpost, Seq.with(
new SectorComplete(stainedMountains),
new SectorComplete(windsweptIslands),
new Research(groundFactory),
new Research(nova),
new Research(airFactory),
new Research(mono)
), () -> {
});
node(saltFlats, Seq.with(
new SectorComplete(windsweptIslands),
new Research(commandCenter),
new Research(groundFactory),
new Research(additiveReconstructor),
new Research(airFactory),
new Research(door)
), () -> {
});
});
});
node(overgrowth, Seq.with(
new SectorComplete(craters),
new SectorComplete(fungalPass),
new Research(cultivator),
new Research(sporePress),
new Research(additiveReconstructor),
new Research(UnitTypes.mace),
new Research(UnitTypes.flare)
), () -> {
});
});
node(biomassFacility, Seq.with(
new SectorComplete(frozenForest),
new Research(powerNode),
new Research(steamGenerator),
new Research(scatter),
new Research(graphitePress)
), () -> {
node(stainedMountains, Seq.with(
new SectorComplete(biomassFacility),
new Research(pneumaticDrill),
new Research(siliconSmelter)
), () -> {
node(fungalPass, Seq.with(
new SectorComplete(stainedMountains),
new Research(groundFactory),
new Research(door),
new Research(siliconSmelter)
), () -> {
node(nuclearComplex, Seq.with(
new SectorComplete(fungalPass),
new Research(thermalGenerator),
new Research(laserDrill),
new Research(Items.plastanium),
new Research(swarmer)
), () -> {
});
});
});
});
});
});
nodeProduce(Items.copper, () -> {
nodeProduce(Liquids.water, () -> {
});
nodeProduce(Items.lead, () -> {
nodeProduce(Items.titanium, () -> {
nodeProduce(Liquids.cryofluid, () -> {
});
nodeProduce(Items.thorium, () -> {
nodeProduce(Items.surgeAlloy, () -> {
});
nodeProduce(Items.phaseFabric, () -> {
});
});
});
nodeProduce(Items.metaglass, () -> {
});
});
nodeProduce(Items.sand, () -> {
nodeProduce(Items.scrap, () -> {
nodeProduce(Liquids.slag, () -> {
});
});
nodeProduce(Items.coal, () -> {
nodeProduce(Items.graphite, () -> {
nodeProduce(Items.silicon, () -> {
});
});
nodeProduce(Items.pyratite, () -> {
nodeProduce(Items.blastCompound, () -> {
});
});
nodeProduce(Items.sporePod, () -> {
});
nodeProduce(Liquids.oil, () -> {
nodeProduce(Items.plastanium, () -> {
});
});
});
});
});
});
public static TechNode nodeRoot(String name, UnlockableContent content, Runnable children){
return nodeRoot(name, content, false, children);
}
public static void setup(){
context = null;
map = new ObjectMap<>();
all = new Seq<>();
public static TechNode nodeRoot(String name, UnlockableContent content, boolean requireUnlock, Runnable children){
var root = node(content, content.researchRequirements(), children);
root.name = name;
root.requiresUnlock = requireUnlock;
roots.add(root);
return root;
}
//all the "node" methods are hidden, because they are for internal context-dependent use only
//for custom research, just use the TechNode constructor
static TechNode node(UnlockableContent content, Runnable children){
public static TechNode node(UnlockableContent content, Runnable children){
return node(content, content.researchRequirements(), children);
}
static TechNode node(UnlockableContent content, ItemStack[] requirements, Runnable children){
public static TechNode node(UnlockableContent content, ItemStack[] requirements, Runnable children){
return node(content, requirements, null, children);
}
static TechNode node(UnlockableContent content, ItemStack[] requirements, Seq<Objective> objectives, Runnable children){
public static TechNode node(UnlockableContent content, ItemStack[] requirements, Seq<Objective> objectives, Runnable children){
TechNode node = new TechNode(context, content, requirements);
if(objectives != null){
node.objectives.addAll(objectives);
@@ -660,61 +50,74 @@ public class TechTree implements ContentList{
return node;
}
static TechNode node(UnlockableContent content, Seq<Objective> objectives, Runnable children){
public static TechNode node(UnlockableContent content, Seq<Objective> objectives, Runnable children){
return node(content, content.researchRequirements(), objectives, children);
}
static TechNode node(UnlockableContent block){
public static TechNode node(UnlockableContent block){
return node(block, () -> {});
}
static TechNode nodeProduce(UnlockableContent content, Seq<Objective> objectives, Runnable children){
return node(content, content.researchRequirements(), objectives.and(new Produce(content)), children);
public static TechNode nodeProduce(UnlockableContent content, Seq<Objective> objectives, Runnable children){
return node(content, content.researchRequirements(), objectives.add(new Produce(content)), children);
}
static TechNode nodeProduce(UnlockableContent content, Runnable children){
public static TechNode nodeProduce(UnlockableContent content, Runnable children){
return nodeProduce(content, new Seq<>(), children);
}
@Nullable
public static TechNode get(UnlockableContent content){
return map.get(content);
}
public static TechNode getNotNull(UnlockableContent content){
return map.getThrow(content, () -> new RuntimeException(content + " does not have a tech node"));
public static @Nullable TechNode context(){
return context;
}
public static class TechNode{
/** Depth in tech tree. */
public int depth;
/** Icon displayed in tech tree selector. */
public @Nullable Drawable icon;
/** Name for root node - used in tech tree selector. */
public @Nullable String name;
/** For roots only. If true, this needs to be unlocked before it is selectable in the research dialog. Does not apply when you are on the planet itself. */
public boolean requiresUnlock = false;
/** Requirement node. */
public @Nullable TechNode parent;
/** Multipliers for research costs on a per-item basis. Inherits from parent. */
public @Nullable ObjectFloatMap<Item> researchCostMultipliers;
/** Content to be researched. */
public UnlockableContent content;
/** Item requirements for this content. */
public ItemStack[] requirements;
/** Requirements that have been fulfilled. Always the same length as the requirement array. */
public final ItemStack[] finishedRequirements;
public ItemStack[] finishedRequirements;
/** Extra objectives needed to research this. */
public Seq<Objective> objectives = new Seq<>();
/** Nodes that depend on this node. */
public final Seq<TechNode> children = new Seq<>();
/** Planet associated with this tech node. Null to auto-detect, or use Serpulo if no associated planet is found. */
public @Nullable Planet planet;
public TechNode(@Nullable TechNode parent, UnlockableContent content, ItemStack[] requirements){
if(parent != null) parent.children.add(this);
if(parent != null){
parent.children.add(this);
planet = parent.planet;
researchCostMultipliers = parent.researchCostMultipliers;
}else if(researchCostMultipliers == null){
researchCostMultipliers = new ObjectFloatMap<>();
}
this.parent = parent;
this.content = content;
this.requirements = requirements;
this.depth = parent == null ? 0 : parent.depth + 1;
this.finishedRequirements = new ItemStack[requirements.length];
//load up the requirements that have been finished if settings are available
for(int i = 0; i < requirements.length; i++){
finishedRequirements[i] = new ItemStack(requirements[i].item, Core.settings == null ? 0 : Core.settings.getInt("req-" + content.name + "-" + requirements[i].item.name));
if(researchCostMultipliers.size > 0){
requirements = ItemStack.copy(requirements);
for(ItemStack requirement : requirements){
requirement.amount = (int)(requirement.amount * researchCostMultipliers.get(requirement.item, 1));
}
}
setupRequirements(requirements);
var used = new ObjectSet<Content>();
//add dependencies as objectives.
@@ -724,10 +127,47 @@ public class TechTree implements ContentList{
}
});
map.put(content, this);
content.techNode = this;
content.techNodes.add(this);
all.add(this);
}
/** Recursively iterates through everything that is a child of this node. Includes itself. */
public void each(Cons<TechNode> consumer){
consumer.get(this);
for(var child : children){
child.each(consumer);
}
}
/** Adds the specified database tab to all the content in this tree. */
public void addDatabaseTab(UnlockableContent tab){
each(node -> node.content.databaseTabs.add(tab));
}
/** Adds the specified planet to the shownPlanets of all the content in this tree. */
public void addPlanet(Planet planet){
each(node -> node.content.shownPlanets.add(planet));
}
public Drawable icon(){
return icon == null ? new TextureRegionDrawable(content.uiIcon) : icon;
}
public String localizedName(){
return Core.bundle.get("techtree." + name, name);
}
public void setupRequirements(ItemStack[] requirements){
this.requirements = requirements;
this.finishedRequirements = new ItemStack[requirements.length];
//load up the requirements that have been finished if settings are available
for(int i = 0; i < requirements.length; i++){
finishedRequirements[i] = new ItemStack(requirements[i].item, Core.settings == null ? 0 : Core.settings.getInt("req-" + content.name + "-" + requirements[i].item.name));
}
}
/** Resets finished requirements and saves. */
public void reset(){
for(ItemStack stack : finishedRequirements){
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -2,23 +2,22 @@ package mindustry.content;
import arc.graphics.*;
import arc.util.*;
import mindustry.ctype.*;
import mindustry.gen.*;
import mindustry.type.*;
import mindustry.type.weather.*;
import mindustry.world.meta.*;
public class Weathers implements ContentList{
public class Weathers{
public static Weather
rain,
snow,
sandstorm,
sporestorm,
fog;
fog,
suspendParticles;
@Override
public void load(){
snow = new ParticleWeather("snow"){{
public static void load(){
snow = new ParticleWeather("snowing"){{
particleRegion = "particle";
sizeMax = 13f;
sizeMin = 2.6f;
@@ -102,5 +101,19 @@ public class Weathers implements ContentList{
attrs.set(Attribute.water, 0.05f);
opacityMultiplier = 0.47f;
}};
suspendParticles = new ParticleWeather("suspend-particles"){{
color = noiseColor = Color.valueOf("a7c1fa");
particleRegion = "particle";
statusGround = false;
useWindVector = true;
hidden = true;
sizeMax = 4f;
sizeMin = 1.4f;
minAlpha = 0.5f;
maxAlpha = 1f;
density = 10000f;
baseSpeed = 0.03f;
}};
}
}
+114 -43
View File
@@ -6,10 +6,12 @@ import arc.func.*;
import arc.graphics.*;
import arc.struct.*;
import arc.util.*;
import mindustry.ai.*;
import mindustry.content.*;
import mindustry.ctype.*;
import mindustry.game.EventType.*;
import mindustry.entities.bullet.*;
import mindustry.game.EventType.*;
import mindustry.io.*;
import mindustry.mod.Mods.*;
import mindustry.type.*;
import mindustry.world.*;
@@ -25,47 +27,36 @@ import static mindustry.Vars.*;
public class ContentLoader{
private ObjectMap<String, MappableContent>[] contentNameMap = new ObjectMap[ContentType.all.length];
private Seq<Content>[] contentMap = new Seq[ContentType.all.length];
private ObjectMap<String, MappableContent> nameMap = new ObjectMap<>();
private MappableContent[][] temporaryMapper;
private @Nullable LoadedMod currentMod;
private @Nullable Content lastAdded;
private ObjectSet<Cons<Content>> initialization = new ObjectSet<>();
private ContentList[] content = {
new Items(),
new StatusEffects(),
new Liquids(),
new Bullets(),
new AmmoTypes(),
new UnitTypes(),
new Blocks(),
new Loadouts(),
new Weathers(),
new Planets(),
new SectorPresets(),
new TechTree(),
};
public ContentLoader(){
clear();
}
/** Clears all initialized content.*/
public void clear(){
contentNameMap = new ObjectMap[ContentType.all.length];
contentMap = new Seq[ContentType.all.length];
initialization = new ObjectSet<>();
for(ContentType type : ContentType.all){
contentMap[type.ordinal()] = new Seq<>();
contentNameMap[type.ordinal()] = new ObjectMap<>();
}
}
/** Creates all base types. */
public void createBaseContent(){
for(ContentList list : content){
list.load();
}
UnitCommand.loadAll();
UnitStance.loadAll();
TeamEntries.load();
Items.load();
StatusEffects.load();
Liquids.load();
Bullets.load();
UnitTypes.load();
Blocks.load();
Loadouts.load();
Weathers.load();
Planets.load();
SectorPresets.load();
SerpuloTechTree.load();
ErekirTechTree.load();
}
/** Creates mod content, if applicable. */
@@ -75,7 +66,7 @@ public class ContentLoader{
}
}
/** Logs content statistics.*/
/** Logs content statistics. */
public void logContent(){
//check up ID mapping, make sure it's linear (debug only)
for(Seq<Content> arr : contentMap){
@@ -91,19 +82,21 @@ public class ContentLoader{
for(int k = 0; k < contentMap.length; k++){
Log.debug("[@]: loaded @", ContentType.all[k].name(), contentMap[k].size);
}
Log.debug("Total content loaded: @", Seq.with(ContentType.all).mapInt(c -> contentMap[c.ordinal()].size).sum());
Log.debug("Total content loaded: @", Seq.with(ContentType.all).sum(c -> contentMap[c.ordinal()].size));
Log.debug("-------------------");
}
/** Calls Content#init() on everything. Use only after all modules have been created.*/
/** Calls Content#init() on everything. Use only after all modules have been created. */
public void init(){
initialize(Content::init);
if(constants != null) constants.init();
initialize(Content::postInit);
if(logicVars != null) logicVars.init();
Events.fire(new ContentInitEvent());
}
/** Calls Content#load() on everything. Use only after all modules have been created on the client.*/
/** Calls Content#loadIcon() and Content#load() on everything. Use only after all modules have been created on the client. */
public void load(){
initialize(Content::loadIcon);
initialize(Content::load);
}
@@ -132,9 +125,9 @@ public class ContentLoader{
/** Loads block colors. */
public void loadColors(){
Pixmap pixmap = new Pixmap(files.internal("sprites/block_colors.png"));
for(int i = 0; i < pixmap.getWidth(); i++){
for(int i = 0; i < pixmap.width; i++){
if(blocks().size > i){
int color = pixmap.getPixel(i, 0);
int color = pixmap.get(i, 0);
if(color == 0 || color == 255) continue;
@@ -150,11 +143,6 @@ public class ContentLoader{
ColorMapper.load();
}
public void dispose(){
initialize(Content::dispose);
clear();
}
/** Get last piece of content created for error-handling purposes. */
public @Nullable Content getLastAdded(){
return lastAdded;
@@ -185,6 +173,13 @@ public class ContentLoader{
public void handleMappableContent(MappableContent content){
if(contentNameMap[content.getContentType().ordinal()].containsKey(content.name)){
var list = contentMap[content.getContentType().ordinal()];
//this method is only called when registering content, and after handleContent.
//If this is the last registered content, and it is invalid, make sure to remove it from the list to prevent invalid stuff from being registered
if(list.size > 0 && list.peek() == content){
list.pop();
}
throw new IllegalArgumentException("Two content objects cannot have the same name! (issue: '" + content.name + "')");
}
if(currentMod != null){
@@ -194,12 +189,18 @@ public class ContentLoader{
}
}
contentNameMap[content.getContentType().ordinal()].put(content.name, content);
nameMap.put(content.name, content);
}
public void setTemporaryMapper(MappableContent[][] temporaryMapper){
this.temporaryMapper = temporaryMapper;
}
/** @return the last registered content with the specified name. Note that the content loader makes no attempt to resolve name conflicts. This method can be unreliable. */
public @Nullable MappableContent byName(String name){
return nameMap.get(name);
}
public Seq<Content>[] getContentMap(){
return contentMap;
}
@@ -211,10 +212,16 @@ public class ContentLoader{
}
public <T extends MappableContent> T getByName(ContentType type, String name){
if(contentNameMap[type.ordinal()] == null){
return null;
var map = contentNameMap[type.ordinal()];
if(map == null) return null;
//load fallbacks
if(type == ContentType.block){
name = SaveVersion.modContentNameMap.get(name, name);
}
return (T)contentNameMap[type.ordinal()].get(name);
return (T)map.get(name);
}
public <T extends Content> T getByID(ContentType type, int id){
@@ -262,6 +269,10 @@ public class ContentLoader{
return getByID(ContentType.item, id);
}
public Item item(String name){
return getByName(ContentType.item, name);
}
public Seq<Liquid> liquids(){
return getBy(ContentType.liquid);
}
@@ -270,6 +281,10 @@ public class ContentLoader{
return getByID(ContentType.liquid, id);
}
public Liquid liquid(String name){
return getByName(ContentType.liquid, name);
}
public Seq<BulletType> bullets(){
return getBy(ContentType.bullet);
}
@@ -278,15 +293,71 @@ public class ContentLoader{
return getByID(ContentType.bullet, id);
}
public Seq<StatusEffect> statusEffects(){
return getBy(ContentType.status);
}
public StatusEffect statusEffect(String name){
return getByName(ContentType.status, name);
}
public Seq<SectorPreset> sectors(){
return getBy(ContentType.sector);
}
public SectorPreset sector(String name){
return getByName(ContentType.sector, name);
}
public Seq<UnitType> units(){
return getBy(ContentType.unit);
}
public UnitType unit(int id){
return getByID(ContentType.unit, id);
}
public UnitType unit(String name){
return getByName(ContentType.unit, name);
}
public Seq<Planet> planets(){
return getBy(ContentType.planet);
}
public Planet planet(String name){
return getByName(ContentType.planet, name);
}
public Seq<Weather> weathers(){
return getBy(ContentType.weather);
}
public Weather weather(String name){
return getByName(ContentType.weather, name);
}
public Seq<UnitStance> unitStances(){
return getBy(ContentType.unitStance);
}
public UnitStance unitStance(int id){
return getByID(ContentType.unitStance, id);
}
public UnitStance unitStance(String name){
return getByName(ContentType.unitStance, name);
}
public Seq<UnitCommand> unitCommands(){
return getBy(ContentType.unitCommand);
}
public UnitCommand unitCommand(int id){
return getByID(ContentType.unitCommand, id);
}
public UnitCommand unitCommand(String name){
return getByName(ContentType.unitCommand, name);
}
}
+266 -130
View File
@@ -16,9 +16,9 @@ import mindustry.content.*;
import mindustry.content.TechTree.*;
import mindustry.core.GameState.*;
import mindustry.entities.*;
import mindustry.game.*;
import mindustry.game.EventType.*;
import mindustry.game.Objectives.*;
import mindustry.game.*;
import mindustry.game.Saves.*;
import mindustry.gen.*;
import mindustry.input.*;
@@ -28,16 +28,15 @@ import mindustry.maps.Map;
import mindustry.maps.*;
import mindustry.net.*;
import mindustry.type.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
import mindustry.world.*;
import mindustry.world.blocks.storage.CoreBlock.*;
import java.io.*;
import java.text.*;
import java.util.*;
import static arc.Core.*;
import static mindustry.Vars.net;
import static mindustry.Vars.*;
/**
@@ -50,14 +49,33 @@ public class Control implements ApplicationListener, Loadable{
public Saves saves;
public SoundControl sound;
public InputHandler input;
public AttackIndicators indicators;
private Interval timer = new Interval(2);
private boolean hiscore = false;
private boolean wasPaused = false;
private boolean wasPaused = false, backgroundPaused = false;
private Seq<Building> toBePlaced = new Seq<>(false);
public Control(){
saves = new Saves();
sound = new SoundControl();
indicators = new AttackIndicators();
Events.on(BuildDamageEvent.class, e -> {
if(e.build.team == Vars.player.team()){
indicators.add(e.build.tileX(), e.build.tileY());
}
});
//show dialog saying that mod loading was skipped.
Events.on(ClientLoadEvent.class, e -> {
if(Vars.mods.skipModLoading() && Vars.mods.list().any()){
Time.runTask(4f, () -> {
ui.showInfo("@mods.initfailed");
});
}
checkAutoUnlocks();
});
Events.on(StateChangeEvent.class, event -> {
if((event.from == State.playing && event.to == State.menu) || (event.from == State.menu && event.to != State.menu)){
@@ -74,7 +92,7 @@ public class Control implements ApplicationListener, Loadable{
Events.on(WorldLoadEvent.class, event -> {
if(Mathf.zero(player.x) && Mathf.zero(player.y)){
Building core = state.teams.closestCore(0, 0, player.team());
Building core = player.bestCore();
if(core != null){
player.set(core);
camera.position.set(core);
@@ -90,6 +108,8 @@ public class Control implements ApplicationListener, Loadable{
Events.on(ResetEvent.class, event -> {
player.reset();
toBePlaced.clear();
indicators.clear();
hiscore = false;
saves.resetSave();
@@ -114,6 +134,10 @@ public class Control implements ApplicationListener, Loadable{
//add player when world loads regardless
Events.on(WorldLoadEvent.class, e -> {
player.add();
//make player admin on any load when hosting
if(net.active() && net.server()){
player.admin = true;
}
});
//autohost for pvp maps
@@ -121,7 +145,7 @@ public class Control implements ApplicationListener, Loadable{
if(state.rules.pvp && !net.active()){
try{
net.host(port);
player.admin(true);
player.admin = true;
}catch(IOException e){
ui.showException("@server.error", e);
state.set(State.menu);
@@ -130,42 +154,28 @@ public class Control implements ApplicationListener, Loadable{
}));
Events.on(UnlockEvent.class, e -> {
ui.hudfrag.showUnlock(e.content);
if(e.content.showUnlock()){
ui.hudfrag.showUnlock(e.content);
}
checkAutoUnlocks();
if(e.content instanceof SectorPreset){
for(TechNode node : TechTree.all){
if(!node.content.unlocked() && node.objectives.contains(o -> o instanceof SectorComplete sec && sec.preset == e.content) && !node.objectives.contains(o -> !o.complete())){
ui.hudfrag.showToast(new TextureRegionDrawable(node.content.icon(Cicon.large)), bundle.get("available"));
ui.hudfrag.showToast(new TextureRegionDrawable(node.content.uiIcon), iconLarge, bundle.get("available"));
}
}
}
});
Events.on(SectorCaptureEvent.class, e -> {
checkAutoUnlocks();
});
app.post(this::checkAutoUnlocks);
Events.on(BlockBuildEndEvent.class, e -> {
if(e.team == player.team()){
if(e.breaking){
state.stats.buildingsDeconstructed++;
}else{
state.stats.buildingsBuilt++;
}
}
});
Events.on(BlockDestroyEvent.class, e -> {
if(e.tile.team() == player.team()){
state.stats.buildingsDestroyed++;
}
});
Events.on(UnitDestroyEvent.class, e -> {
if(e.unit.team() != player.team()){
state.stats.enemyUnitsDestroyed++;
if(!net.client() && e.sector.preset != null && e.sector.preset.isLastSector && e.initialCapture){
Time.run(60f * 2f, () -> {
ui.campaignComplete.show(e.sector.planet);
});
}
});
@@ -181,35 +191,104 @@ public class Control implements ApplicationListener, Loadable{
});
Events.run(Trigger.newGame, () -> {
Building core = player.closestCore();
var core = player.bestCore();
if(core == null) return;
//TODO this sounds pretty bad due to conflict
if(settings.getInt("musicvol") > 0){
Musics.land.stop();
Musics.land.play();
Musics.land.setVolume(settings.getInt("musicvol") / 100f);
}
app.post(() -> ui.hudfrag.showLand());
renderer.zoomIn(Fx.coreLand.lifetime);
app.post(() -> Fx.coreLand.at(core.getX(), core.getY(), 0, core.block));
camera.position.set(core);
player.set(core);
Time.run(Fx.coreLand.lifetime, () -> {
Fx.launch.at(core);
Effect.shake(5f, 5f, core);
if(state.isCampaign()){
ui.announce("[accent]" + state.rules.sector.name() + "\n" +
(state.rules.sector.info.resources.any() ? "[lightgray]" + bundle.get("sectors.resources") + "[white] " +
state.rules.sector.info.resources.toString(" ", u -> u.emoji()) : ""), 5);
float coreDelay = 0f;
if(!settings.getBool("skipcoreanimation") && !state.rules.pvp){
coreDelay = core.launchDuration();
//delay player respawn so animation can play.
player.deathTimer = Player.deathDelay - core.launchDuration();
//TODO this sounds pretty bad due to conflict
if(settings.getInt("musicvol") > 0){
//TODO what to do if another core with different music is already playing?
Music music = core.landMusic();
music.stop();
music.play();
music.setVolume(settings.getInt("musicvol") / 100f);
}
});
renderer.showLanding(core);
}
if(state.isCampaign()){
if(state.rules.sector.info.importRateCache != null){
state.rules.sector.info.refreshImportRates(state.rules.sector.planet);
}
//don't run when hosting, that doesn't really work.
if(state.rules.sector.planet.prebuildBase){
toBePlaced.clear();
float unitsPerTick = 2f;
float buildRadius = state.rules.enemyCoreBuildRadius * 1.5f;
//TODO if the save is unloaded or map is hosted, these blocks do not get built.
boolean anyBuilds = false;
for(var build : state.rules.defaultTeam.data().buildings.copy()){
if(!(build instanceof CoreBuild) && !build.block.privileged){
var ccore = build.closestCore();
if(ccore != null){
anyBuilds = true;
if(!net.active()){
build.pickedUp();
build.tile.remove();
toBePlaced.add(build);
Time.run(build.dst(ccore) / unitsPerTick + coreDelay, () -> {
if(build.tile.build != build){
placeLandBuild(build);
toBePlaced.remove(build);
}
});
}else{
//when already hosting, instantly build everything. this looks bad but it's better than a desync
Fx.coreBuildBlock.at(build.x, build.y, 0f, build.block);
build.block.placeEffect.at(build.x, build.y, build.block.size);
}
}
}
}
if(anyBuilds){
for(var ccore : state.rules.defaultTeam.data().cores){
Time.run(coreDelay, () -> {
Fx.coreBuildShockwave.at(ccore.x, ccore.y, buildRadius);
});
}
}
}
}
});
Events.on(SaveWriteEvent.class, e -> forcePlaceAll());
Events.on(HostEvent.class, e -> forcePlaceAll());
Events.on(HostEvent.class, e -> {
state.set(State.playing);
});
}
private void forcePlaceAll(){
//force set buildings when a save is done or map is hosted, to prevent desyncs
for(var build : toBePlaced){
placeLandBuild(build);
}
toBePlaced.clear();
}
private void placeLandBuild(Building build){
build.tile.setBlock(build.block, build.team, build.rotation, () -> build);
build.dropped();
Fx.coreBuildBlock.at(build.x, build.y, 0f, build.block);
build.block.placeEffect.at(build.x, build.y, build.block.size);
}
@Override
@@ -230,12 +309,12 @@ public class Control implements ApplicationListener, Loadable{
saves.load();
}
/** Automatically unlocks things with no requirements. */
void checkAutoUnlocks(){
/** Automatically unlocks things with no requirements and no locked parents. */
public void checkAutoUnlocks(){
if(net.client()) return;
for(TechNode node : TechTree.all){
if(!node.content.unlocked() && node.requirements.length == 0 && !node.objectives.contains(o -> !o.complete())){
if(!node.content.unlocked() && (node.parent == null || node.parent.content.unlocked()) && node.requirements.length == 0 && !node.objectives.contains(o -> !o.complete())){
node.content.unlock();
}
}
@@ -244,6 +323,13 @@ public class Control implements ApplicationListener, Loadable{
void createPlayer(){
player = Player.create();
player.name = Core.settings.getString("name");
String locale = Core.settings.getString("locale");
if(locale.equals("default")){
locale = Locale.getDefault().toString();
}
player.locale = locale;
player.color.set(Core.settings.getInt("color-0"));
if(mobile){
@@ -271,17 +357,31 @@ public class Control implements ApplicationListener, Loadable{
}
public void playMap(Map map, Rules rules){
playMap(map, rules, false);
}
public void playMap(Map map, Rules rules, boolean playtest){
ui.loadAnd(() -> {
logic.reset();
world.loadMap(map, rules);
state.rules = rules;
if(playtest) state.playtestingMap = map;
state.rules.sector = null;
state.rules.editor = false;
logic.play();
if(settings.getBool("savecreate") && !world.isInvalidMap()){
if(settings.getBool("savecreate") && !world.isInvalidMap() && !playtest){
control.saves.addSave(map.name() + " " + new SimpleDateFormat("MMM dd h:mm", Locale.getDefault()).format(new Date()));
}
Events.fire(Trigger.newGame);
//booted out of map, resume editing
if(world.isInvalidMap() && playtest){
Dialog current = scene.getDialog();
ui.editor.resumeAfterPlaytest(map);
if(current != null){
current.update(current::toFront);
}
}
});
}
@@ -300,82 +400,101 @@ public class Control implements ApplicationListener, Loadable{
control.saves.resetSave();
}
//for planet launches, mostly
if(sector.preset != null){
sector.preset.quietUnlock();
}
ui.planet.hide();
SaveSlot slot = sector.save;
sector.planet.setLastSector(sector);
if(slot != null && !clearSectors){
if(slot != null && !clearSectors && (!(sector.planet.clearSectorOnLose || sector.info.hasWorldProcessor) || sector.info.hasCore)){
try{
boolean hadNoCore = !sector.info.hasCore;
reloader.begin();
slot.load();
slot.setAutosave(true);
state.rules.sector = sector;
state.rules.cloudColor = sector.planet.landCloudColor;
//if there is no base, simulate a new game and place the right loadout at the spawn position
if(state.rules.defaultTeam.cores().isEmpty()){
if(state.rules.defaultTeam.cores().isEmpty() || hadNoCore){
//no spawn set -> delete the sector save
if(sector.info.spawnPosition == 0){
//delete old save
sector.save = null;
slot.delete();
//play again
playSector(origin, sector, reloader);
return;
}
if(sector.planet.clearSectorOnLose || sector.info.hasWorldProcessor){
playNewSector(origin, sector, reloader);
}else{
//no spawn set -> delete the sector save
if(sector.info.spawnPosition == 0){
//delete old save
sector.save = null;
slot.delete();
//play again
playSector(origin, sector, reloader);
return;
}
//set spawn for sector damage to use
Tile spawn = world.tile(sector.info.spawnPosition);
spawn.setBlock(Blocks.coreShard, state.rules.defaultTeam);
//set spawn for sector damage to use
Tile spawn = world.tile(sector.info.spawnPosition);
spawn.setBlock(sector.planet.defaultCore, state.rules.defaultTeam);
//add extra damage.
SectorDamage.apply(1f);
//add extra damage.
SectorDamage.apply(1f);
//reset wave so things are more fair
state.wave = 1;
//set up default wave time
state.wavetime = state.rules.waveSpacing * 2f;
//reset captured state
sector.info.wasCaptured = false;
//re-enable waves
state.rules.waves = true;
//reset wave so things are more fair
state.wave = 1;
//set up default wave time
state.wavetime = state.rules.initialWaveSpacing <= 0f ? (state.rules.waveSpacing * (sector.preset == null ? 2f : sector.preset.startWaveTimeMultiplier)) : state.rules.initialWaveSpacing;
state.wavetime *= sector.planet.campaignRules.difficulty.waveTimeMultiplier;
//reset captured state
sector.info.wasCaptured = false;
//reset win wave??
state.rules.winWave = state.rules.attackMode ? -1 : sector.preset != null && sector.preset.captureWave > 0 ? sector.preset.captureWave : state.rules.winWave > state.wave ? state.rules.winWave : 30;
if(state.rules.sector.planet.allowWaves){
//re-enable waves
state.rules.waves = true;
//reset win wave??
state.rules.winWave = state.rules.attackMode ? -1 : sector.preset != null && sector.preset.captureWave > 0 ? sector.preset.captureWave : state.rules.winWave > state.wave ? state.rules.winWave : 30;
}
//if there's still an enemy base left, fix it
if(state.rules.attackMode){
//replace all broken blocks
for(var plan : state.rules.waveTeam.data().blocks){
Tile tile = world.tile(plan.x, plan.y);
if(tile != null){
tile.setBlock(content.block(plan.block), state.rules.waveTeam, plan.rotation);
if(plan.config != null && tile.build != null){
tile.build.configureAny(plan.config);
//if there's still an enemy base left, fix it
if(state.rules.attackMode){
//replace all broken blocks
for(var plan : state.rules.waveTeam.data().plans){
Tile tile = world.tile(plan.x, plan.y);
if(tile != null){
tile.setBlock(plan.block, state.rules.waveTeam, plan.rotation);
if(plan.config != null && tile.build != null){
tile.build.configureAny(plan.config);
}
}
}
state.rules.waveTeam.data().plans.clear();
}
state.rules.waveTeam.data().blocks.clear();
//kill all units, since they should be dead anyway
Groups.unit.clear();
Groups.fire.clear();
Groups.puddle.clear();
//reset to 0, so replaced cores don't count
state.rules.defaultTeam.data().unitCap = 0;
Schematics.placeLaunchLoadout(spawn.x, spawn.y);
//set up camera/player locations
player.set(spawn.x * tilesize, spawn.y * tilesize);
camera.position.set(player);
Events.fire(new SectorLaunchEvent(sector));
Events.fire(Trigger.newGame);
state.set(State.playing);
reloader.end();
}
//kill all units, since they should be dead anyway
Groups.unit.clear();
Groups.fire.clear();
Groups.puddle.clear();
Schematics.placeLaunchLoadout(spawn.x, spawn.y);
//set up camera/player locations
player.set(spawn.x * tilesize, spawn.y * tilesize);
camera.position.set(player);
Events.fire(new SectorLaunchEvent(sector));
Events.fire(Trigger.newGame);
}else{
state.set(State.playing);
reloader.end();
}
state.set(State.playing);
reloader.end();
}catch(SaveException e){
Log.err(e);
sector.save = null;
@@ -385,21 +504,26 @@ public class Control implements ApplicationListener, Loadable{
}
ui.planet.hide();
}else{
reloader.begin();
world.loadSector(sector);
state.rules.sector = sector;
//assign origin when launching
sector.info.origin = origin;
sector.info.destination = origin;
logic.play();
control.saves.saveSector(sector);
Events.fire(new SectorLaunchEvent(sector));
Events.fire(Trigger.newGame);
reloader.end();
playNewSector(origin, sector, reloader);
}
});
}
public void playNewSector(@Nullable Sector origin, Sector sector, WorldReloader reloader){
reloader.begin();
world.loadSector(sector);
state.rules.sector = sector;
//assign origin when launching
sector.info.origin = origin;
sector.info.destination = origin;
logic.play();
control.saves.saveSector(sector);
Events.fire(new SectorLaunchEvent(sector));
Events.fire(Trigger.newGame);
reloader.end();
state.set(State.playing);
}
public boolean isHighScore(){
return hiscore;
}
@@ -407,7 +531,7 @@ public class Control implements ApplicationListener, Loadable{
@Override
public void dispose(){
//try to save when exiting
if(saves != null && saves.getCurrent() != null && saves.getCurrent().isAutosave() && !net.client() && !state.isMenu()){
if(saves != null && saves.getCurrent() != null && saves.getCurrent().isAutosave() && !net.client() && !state.isMenu() && !state.gameOver){
try{
SaveIO.save(control.saves.getCurrent().file);
Log.info("Saved on exit.");
@@ -420,16 +544,13 @@ public class Control implements ApplicationListener, Loadable{
music.stop();
}
content.dispose();
net.dispose();
Musics.dispose();
Sounds.dispose();
if(ui != null && ui.editor != null) ui.editor.dispose();
}
@Override
public void pause(){
if(settings.getBool("backgroundpause", true)){
if(settings.getBool("backgroundpause", true) && !net.active()){
backgroundPaused = true;
wasPaused = state.is(State.paused);
if(state.is(State.playing)) state.set(State.paused);
}
@@ -437,9 +558,10 @@ public class Control implements ApplicationListener, Loadable{
@Override
public void resume(){
if(state.is(State.paused) && !wasPaused && settings.getBool("backgroundpause", true)){
if(state.is(State.paused) && !wasPaused && settings.getBool("backgroundpause", true) && !net.active()){
state.set(State.playing);
}
backgroundPaused = false;
}
@Override
@@ -502,7 +624,7 @@ public class Control implements ApplicationListener, Loadable{
if(full){
graphics.setWindowedMode(graphics.getWidth(), graphics.getHeight());
}else{
graphics.setFullscreenMode(graphics.getDisplayMode());
graphics.setFullscreen();
}
settings.put("fullscreen", !full);
}
@@ -516,6 +638,9 @@ public class Control implements ApplicationListener, Loadable{
if(state.isGame()){
input.update();
if(!state.isPaused()){
indicators.update();
}
//auto-update rpc every 5 seconds
if(timer.get(0, 60 * 5)){
@@ -528,8 +653,17 @@ public class Control implements ApplicationListener, Loadable{
core.items.each((i, a) -> i.unlock());
}
if(Core.input.keyTap(Binding.pause) && !scene.hasDialog() && !scene.hasKeyboard() && !ui.restart.isShown() && (state.is(State.paused) || state.is(State.playing))){
state.set(state.is(State.playing) ? State.paused : State.playing);
if(backgroundPaused && settings.getBool("backgroundpause") && !net.active()){
state.set(State.paused);
}
//cannot launch while paused
if(state.isPaused() && renderer.isCutscene()){
state.set(State.playing);
}
if(!net.client() && Core.input.keyTap(Binding.pause) && !renderer.isCutscene() && !scene.hasDialog() && !scene.hasKeyboard() && !ui.restart.isShown() && (state.is(State.paused) || state.is(State.playing))){
state.set(state.isPaused() ? State.playing : State.paused);
}
if(Core.input.keyTap(Binding.menu) && !ui.restart.isShown() && !ui.minimapfrag.shown()){
@@ -537,11 +671,13 @@ public class Control implements ApplicationListener, Loadable{
ui.chatfrag.hide();
}else if(!ui.paused.isShown() && !scene.hasDialog()){
ui.paused.show();
state.set(State.paused);
if(!net.active()){
state.set(State.paused);
}
}
}
if(!mobile && Core.input.keyTap(Binding.screenshot) && !(scene.getKeyboardFocus() instanceof TextField) && !scene.hasKeyboard()){
if(!mobile && Core.input.keyTap(Binding.screenshot) && !scene.hasField() && !scene.hasKeyboard()){
renderer.takeMapScreenshot();
}
+46 -1
View File
@@ -2,15 +2,22 @@ package mindustry.core;
import arc.*;
import arc.assets.loaders.*;
import arc.assets.loaders.MusicLoader.*;
import arc.assets.loaders.SoundLoader.*;
import arc.audio.*;
import arc.files.*;
import arc.struct.*;
import mindustry.*;
import mindustry.gen.*;
/** Handles files in a modded context. */
public class FileTree implements FileHandleResolver{
private ObjectMap<String, Fi> files = new ObjectMap<>();
private ObjectMap<String, Sound> loadedSounds = new ObjectMap<>();
private ObjectMap<String, Music> loadedMusic = new ObjectMap<>();
public void addFile(String path, Fi f){
files.put(path, f);
files.put(path.replace('\\', '/'), f);
}
/** Gets an asset file.*/
@@ -40,4 +47,42 @@ public class FileTree implements FileHandleResolver{
public Fi resolve(String fileName){
return get(fileName);
}
/**
* Loads a sound by name from the sounds/ folder. OGG and MP3 are supported; the extension is automatically added to the end of the file name.
* Results are cached; consecutive calls to this method with the same name will return the same sound instance.
* */
public Sound loadSound(String soundName){
if(Vars.headless) return Sounds.none;
return loadedSounds.get(soundName, () -> {
String name = "sounds/" + soundName;
String path = Vars.tree.get(name + ".ogg").exists() ? name + ".ogg" : name + ".mp3";
var sound = new Sound();
var desc = Core.assets.load(path, Sound.class, new SoundParameter(sound));
desc.errored = Throwable::printStackTrace;
return sound;
});
}
/**
* Loads a music file by name from the music/ folder. OGG and MP3 are supported; the extension is automatically added to the end of the file name.
* Results are cached; consecutive calls to this method with the same name will return the same music instance.
* */
public Music loadMusic(String musicName){
if(Vars.headless) return new Music();
return loadedMusic.get(musicName, () -> {
String name = "music/" + musicName;
String path = Vars.tree.get(name + ".ogg").exists() ? name + ".ogg" : name + ".mp3";
var music = new Music();
var desc = Core.assets.load(path, Music.class, new MusicParameter(music));
desc.errored = Throwable::printStackTrace;
return music;
});
}
}
+28 -9
View File
@@ -16,37 +16,52 @@ public class GameState{
public int wave = 1;
/** Wave countdown in ticks. */
public float wavetime;
/** Logic tick. */
public double tick;
/** Continuously ticks up every non-paused update. */
public long updateId;
/** Whether the game is in game over state. */
public boolean gameOver = false, serverPaused = false, wasTimeout;
public boolean gameOver = false;
/** Whether the player's team won the match. */
public boolean won = false;
/** Server ticks/second. Only valid in multiplayer. */
public int serverTps = -1;
/** Map that is currently being played on. */
public Map map = emptyMap;
/** The current game rules. */
public Rules rules = new Rules();
/** Statistics for this save/game. Displayed after game over. */
public GameStats stats = new GameStats();
/** Markers not linked to objectives. Controlled by world processors. */
public MapMarkers markers = new MapMarkers();
/** Locale-specific string bundles of current map */
public MapLocales mapLocales = new MapLocales();
/** Global attributes of the environment, calculated by weather. */
public Attributes envAttrs = new Attributes();
/** Team data. Gets reset every new game. */
public Teams teams = new Teams();
/** Number of enemies in the game; only used clientside in servers. */
public int enemies;
/** Map being playtested (not edited!) */
public @Nullable Map playtestingMap;
/** Current game state. */
private State state = State.menu;
@Nullable
public Unit boss(){
return teams.boss;
return teams.bosses.firstOpt();
}
public void set(State astate){
//cannot pause when in multiplayer
if(astate == State.paused && net.active()) return;
//nothing to change.
if(state == astate) return;
Events.fire(new StateChangeEvent(state, astate));
state = astate;
}
public boolean hasSpawns(){
return rules.waves && !(isCampaign() && rules.attackMode);
return rules.waves && ((rules.waveTeam.cores().size > 0 && rules.attackMode) || rules.spawns.size > 0);
}
/** Note that being in a campaign does not necessarily mean having a sector. */
@@ -58,21 +73,25 @@ public class GameState{
return rules.sector != null;
}
@Nullable
public Sector getSector(){
public @Nullable Sector getSector(){
return rules.sector;
}
public @Nullable Planet getPlanet(){
return rules.sector != null ? rules.sector.planet : rules.planet;
}
public boolean isEditor(){
return rules.editor;
}
public boolean isPaused(){
return (is(State.paused) && !net.active()) || (gameOver && (!net.active() || isCampaign())) || (serverPaused && !isMenu());
return state == State.paused;
}
/** @return whether there is an unpaused game in progress. */
public boolean isPlaying(){
return (state == State.playing) || (state == State.paused && !isPaused());
return state == State.playing;
}
/** @return whether the current state is *not* the menu. */
+208 -127
View File
@@ -3,6 +3,7 @@ package mindustry.core;
import arc.*;
import arc.math.*;
import arc.util.*;
import mindustry.ai.*;
import mindustry.annotations.Annotations.*;
import mindustry.core.GameState.*;
import mindustry.ctype.*;
@@ -14,6 +15,7 @@ import mindustry.maps.*;
import mindustry.type.*;
import mindustry.type.Weather.*;
import mindustry.world.*;
import mindustry.world.blocks.storage.CoreBlock.*;
import java.util.*;
@@ -32,85 +34,108 @@ public class Logic implements ApplicationListener{
public Logic(){
Events.on(BlockDestroyEvent.class, event -> {
//skip if rule is off
if(!state.rules.ghostBlocks) return;
//blocks that get broken are appended to the team's broken block queue
Tile tile = event.tile;
//skip null entities or un-rebuildables, for obvious reasons; also skip client since they can't modify these requests
if(tile.build == null || !tile.block().rebuildable || net.client()) return;
//skip null entities or un-rebuildables, for obvious reasons
if(tile.build == null || !tile.block().rebuildable) return;
tile.build.addPlan(true);
});
Events.on(BlockBuildEndEvent.class, event -> {
if(!event.breaking){
TeamData data = state.teams.get(event.team);
Iterator<BlockPlan> it = data.blocks.iterator();
while(it.hasNext()){
BlockPlan b = it.next();
Block block = content.block(b.block);
if(event.tile.block().bounds(event.tile.x, event.tile.y, Tmp.r1).overlaps(block.bounds(b.x, b.y, Tmp.r2))){
it.remove();
}
checkOverlappingPlans(event.team, event.tile);
if(event.team == state.rules.defaultTeam){
state.stats.placedBlockCount.increment(event.tile.block());
}
}
});
Events.on(PayloadDropEvent.class, e -> {
if(e.build != null){
checkOverlappingPlans(e.build.team, e.build.tile);
}
});
//when loading a 'damaged' sector, propagate the damage
Events.on(SaveLoadEvent.class, e -> {
if(state.isCampaign()){
SectorInfo info = state.rules.sector.info;
info.write();
state.rules.coreIncinerates = true;
//how much wave time has passed
int wavesPassed = info.wavesPassed;
//TODO why is this even a thing?
state.rules.canGameOver = true;
//wave has passed, remove all enemies, they are assumed to be dead
if(wavesPassed > 0){
Groups.unit.each(u -> {
if(u.team == state.rules.waveTeam){
u.remove();
//fresh map has no sector info
if(!e.isMap){
SectorInfo info = state.rules.sector.info;
info.write();
//only simulate waves if the planet allows it
if(state.rules.sector.planet.allowWaveSimulation){
//how much wave time has passed
int wavesPassed = info.wavesPassed;
//wave has passed, remove all enemies, they are assumed to be dead
if(wavesPassed > 0){
Groups.unit.each(u -> {
if(u.team == state.rules.waveTeam){
u.remove();
}
});
}
});
}
//simulate passing of waves
if(wavesPassed > 0){
//simulate wave counter moving forward
state.wave += wavesPassed;
state.wavetime = state.rules.waveSpacing;
//simulate passing of waves
if(wavesPassed > 0){
//simulate wave counter moving forward
state.wave += wavesPassed;
state.wavetime = state.rules.waveSpacing * state.getPlanet().campaignRules.difficulty.waveTimeMultiplier;
SectorDamage.applyCalculatedDamage();
//make sure damaged buildings are counted
for(Tile tile : world.tiles){
if(tile.build != null && tile.build.damaged()){
indexer.notifyTileDamaged(tile.build);
SectorDamage.applyCalculatedDamage();
}
}
state.getSector().planet.applyRules(state.rules);
//reset values
info.damage = 0f;
info.wavesPassed = 0;
info.hasCore = true;
info.secondsPassed = 0;
state.rules.sector.saveInfo();
}
//reset values
info.damage = 0f;
info.wavesPassed = 0;
info.hasCore = true;
info.secondsPassed = 0;
state.rules.sector.saveInfo();
}
});
Events.on(PlayEvent.class, e -> {
//reset weather on play
var randomWeather = state.rules.weather.copy().shuffle();
float sum = 0f;
for(var weather : randomWeather){
weather.cooldown = sum + Mathf.random(weather.maxFrequency);
sum += weather.cooldown;
}
//tick resets on new save play
state.tick = 0f;
});
Events.on(WorldLoadEvent.class, e -> {
//enable infinite ammo for wave team by default
state.rules.waveTeam.rules().infiniteAmmo = true;
if(state.isCampaign()){
//enable building AI on campaign unless the preset disables it
if(!(state.getSector().preset != null && !state.getSector().preset.useAI)){
state.rules.waveTeam.rules().ai = true;
}
state.rules.waveTeam.rules().aiTier = state.getSector().threat * 0.8f;
state.rules.waveTeam.rules().infiniteResources = true;
//fill enemy cores by default.
state.rules.coreIncinerates = true;
state.rules.allowEditWorldProcessors = false;
state.rules.waveTeam.rules().infiniteResources = true;
state.rules.waveTeam.rules().buildSpeedMultiplier *= state.getPlanet().enemyBuildSpeedMultiplier;
//fill enemy cores by default? TODO decide
for(var core : state.rules.waveTeam.cores()){
for(Item item : content.items()){
core.items.set(item, core.block.itemCapacity);
@@ -131,61 +156,95 @@ public class Logic implements ApplicationListener{
Events.on(SectorCaptureEvent.class, e -> {
if(!net.client() && e.sector == state.getSector() && e.sector.isBeingPlayed()){
for(Tile tile : world.tiles){
//convert all blocks to neutral, randomly killing them
if(tile.isCenter() && tile.build != null && tile.build.team == state.rules.waveTeam){
Building b = tile.build;
Call.setTeam(b, Team.derelict);
Time.run(Mathf.random(0f, 60f * 6f), () -> {
if(Mathf.chance(0.25)){
b.kill();
}
});
}
state.rules.waveTeam.data().destroyToDerelict();
}
});
Events.on(BlockDestroyEvent.class, e -> {
if(e.tile.build instanceof CoreBuild core && core.team.isAI() && state.rules.coreDestroyClear){
Core.app.post(() -> {
core.team.data().timeDestroy(core.x, core.y, state.rules.enemyCoreBuildRadius);
});
}
});
//listen to core changes; if all cores have been destroyed, set to derelict.
Events.on(CoreChangeEvent.class, e -> Core.app.post(() -> {
if(state.rules.cleanupDeadTeams && state.rules.pvp && !e.core.isAdded() && e.core.team != Team.derelict && e.core.team.cores().isEmpty()){
e.core.team.data().destroyToDerelict();
}
}));
Events.on(BlockBuildEndEvent.class, e -> {
if(e.team == state.rules.defaultTeam){
if(e.breaking){
state.stats.buildingsDeconstructed++;
}else{
state.stats.buildingsBuilt++;
}
//kill all units
Groups.unit.each(u -> {
if(u.team == state.rules.waveTeam){
Time.run(Mathf.random(0f, 60f * 5f), u::kill);
}
});
}
});
//send out items to each client
Events.on(TurnEvent.class, e -> {
if(net.server() && state.isCampaign()){
int[] out = new int[content.items().size];
state.getSector().info.production.each((item, stat) -> {
out[item.id] = Math.max(0, (int)(stat.mean * turnDuration / 60));
});
Call.sectorProduced(out);
Events.on(BlockDestroyEvent.class, e -> {
if(e.tile.team() == state.rules.defaultTeam){
state.stats.buildingsDestroyed ++;
}
});
Events.on(UnitDestroyEvent.class, e -> {
if(e.unit.team() != state.rules.defaultTeam){
state.stats.enemyUnitsDestroyed ++;
}
});
Events.on(UnitCreateEvent.class, e -> {
if(e.unit.team == state.rules.defaultTeam){
state.stats.unitsCreated++;
}
});
}
private void checkOverlappingPlans(Team team, Tile tile){
TeamData data = team.data();
Iterator<BlockPlan> it = data.plans.iterator();
var bounds = tile.block().bounds(tile.x, tile.y, Tmp.r1);
while(it.hasNext()){
BlockPlan b = it.next();
if(bounds.overlaps(b.block.bounds(b.x, b.y, Tmp.r2))){
b.removed = true;
it.remove();
}
}
}
/** Adds starting items, resets wave time, and sets state to playing. */
public void play(){
state.set(State.playing);
//grace period of 2x wave time before game starts
state.wavetime = state.rules.waveSpacing * 2;
state.wavetime = (state.rules.initialWaveSpacing <= 0 ? state.rules.waveSpacing * 2 : state.rules.initialWaveSpacing) * (state.isCampaign() ? state.getPlanet().campaignRules.difficulty.waveTimeMultiplier : 1f);;
Events.fire(new PlayEvent());
//add starting items
if(!state.isCampaign()){
if(!state.isCampaign() || !state.rules.sector.planet.allowLaunchLoadout || (state.rules.sector.preset != null && state.rules.sector.preset.addStartingItems)){
for(TeamData team : state.teams.getActive()){
if(team.hasCore()){
Building entity = team.core();
CoreBuild entity = team.core();
entity.items.clear();
for(ItemStack stack : state.rules.loadout){
entity.items.add(stack.item, stack.amount);
//make sure to cap storage
entity.items.add(stack.item, Math.min(stack.amount, entity.storageCapacity - entity.items.get(stack.item)));
}
}
}
}
//heal all cores on game start
for(TeamData team : state.teams.getActive()){
for(var entity : team.cores){
entity.heal();
}
}
}
public void reset(){
@@ -198,19 +257,20 @@ public class Logic implements ApplicationListener{
Groups.clear();
Time.clear();
Events.fire(new ResetEvent());
world.tiles = new Tiles(0, 0);
//save settings on reset
Core.settings.manualSave();
}
public void skipWave(){
state.wavetime = 0;
runWave();
}
public void runWave(){
spawner.spawnEnemies();
state.wave++;
state.wavetime = state.rules.waveSpacing;
state.wavetime = state.rules.waveSpacing * (state.isCampaign() ? state.getPlanet().campaignRules.difficulty.waveTimeMultiplier : 1f);
Events.fire(new WaveEvent());
}
@@ -234,7 +294,14 @@ public class Logic implements ApplicationListener{
if(state.rules.waves && (state.enemies == 0 && state.rules.winWave > 0 && state.wave >= state.rules.winWave && !spawner.isSpawning()) ||
(state.rules.attackMode && state.rules.waveTeam.cores().isEmpty())){
Call.sectorCapture();
if(state.rules.sector.preset != null && state.rules.sector.preset.attackAfterWaves && !state.rules.attackMode){
//activate attack mode to destroy cores after waves are done.
state.rules.attackMode = true;
state.rules.waves = false;
Call.setRules(state.rules);
}else{
Call.sectorCapture();
}
}
}else{
if(!state.rules.attackMode && state.teams.playerCores().size == 0 && !state.gameOver){
@@ -242,19 +309,22 @@ public class Logic implements ApplicationListener{
Events.fire(new GameOverEvent(state.rules.waveTeam));
}else if(state.rules.attackMode){
//count # of teams alive
int countAlive = state.teams.getActive().count(TeamData::hasCore);
int countAlive = state.teams.getActive().count(t -> t.hasCore() && t.team != Team.derelict);
if((countAlive <= 1 || (!state.rules.pvp && state.rules.defaultTeam.core() == null)) && !state.gameOver){
//find team that won
TeamData left = state.teams.getActive().find(TeamData::hasCore);
TeamData left = state.teams.getActive().find(t -> t.hasCore() && t.team != Team.derelict);
Events.fire(new GameOverEvent(left == null ? Team.derelict : left.team));
state.gameOver = true;
}
}else if(!state.gameOver && state.rules.waves && (state.enemies == 0 && state.rules.winWave > 0 && state.wave >= state.rules.winWave && !spawner.isSpawning())){
state.gameOver = true;
Events.fire(new GameOverEvent(state.rules.defaultTeam));
}
}
}
private void updateWeather(){
protected void updateWeather(){
state.rules.weather.removeAll(w -> w.weather == null);
for(WeatherEntry entry : state.rules.weather){
@@ -282,14 +352,21 @@ public class Logic implements ApplicationListener{
return;
}
boolean initial = !state.rules.sector.info.wasCaptured;
state.rules.sector.info.wasCaptured = true;
//fire capture event
Events.fire(new SectorCaptureEvent(state.rules.sector));
Events.fire(new SectorCaptureEvent(state.rules.sector, initial));
//disable attack mode
state.rules.attackMode = false;
//map is over, no more world processor objective stuff
state.rules.disableWorldProcessors = true;
Call.clearObjectives();
//save, just in case
if(!headless && !net.client()){
control.saves.saveSector(state.rules.sector);
@@ -299,12 +376,16 @@ public class Logic implements ApplicationListener{
@Remote(called = Loc.both)
public static void updateGameOver(Team winner){
state.gameOver = true;
if(!headless){
state.won = player.team() == winner;
}
}
@Remote(called = Loc.both)
public static void gameOver(Team winner){
state.stats.wavesLasted = state.wave;
ui.restart.show(winner);
state.won = player.team() == winner;
Time.run(60f * 3f, () -> ui.restart.show(winner));
netClient.setQuiet();
}
@@ -313,51 +394,20 @@ public class Logic implements ApplicationListener{
public static void researched(Content content){
if(!(content instanceof UnlockableContent u)) return;
var node = u.node();
boolean was = u.unlockedNowHost();
state.rules.researched.add(u);
//unlock all direct dependencies on client, permanently
while(node != null){
node.content.unlock();
node = node.parent;
}
state.rules.researched.add(u.name);
}
//called when the remote server runs a turn and produces something
@Remote
public static void sectorProduced(int[] amounts){
if(!state.isCampaign()) return;
Planet planet = state.rules.sector.planet;
boolean any = false;
for(Item item : content.items()){
int am = amounts[item.id];
if(am > 0){
int sumMissing = planet.sectors.sum(s -> s.hasBase() ? s.info.storageCapacity - s.info.items.get(item) : 0);
if(sumMissing == 0) continue;
//how much % to add
double percent = Math.min((double)am / sumMissing, 1);
for(Sector sec : planet.sectors){
if(sec.hasBase()){
int added = (int)Math.ceil(((sec.info.storageCapacity - sec.info.items.get(item)) * percent));
sec.info.items.add(item, added);
any = true;
}
}
}
}
if(any){
for(Sector sec : planet.sectors){
sec.saveInfo();
}
if(!was){
Events.fire(new UnlockEvent(u));
}
}
@Override
public void dispose(){
//save the settings before quitting
if(netServer != null){
netServer.admins.forceSave();
}
Core.settings.manualSave();
}
@@ -367,16 +417,29 @@ public class Logic implements ApplicationListener{
universe.updateGlobal();
if(Core.settings.modified() && !state.isPlaying()){
netServer.admins.forceSave();
Core.settings.forceSave();
}
boolean runStateCheck = !net.client() && !world.isInvalidMap() && !state.isEditor() && state.rules.canGameOver;
if(state.isGame()){
if(!net.client()){
state.enemies = Groups.unit.count(u -> u.team() == state.rules.waveTeam && u.type.isCounted);
state.enemies = Groups.unit.count(u -> u.team() == state.rules.waveTeam && u.isEnemy());
}
if(!state.isPaused()){
Events.fire(Trigger.beforeGameUpdate);
float delta = Core.graphics.getDeltaTime();
state.tick += Float.isNaN(delta) || Float.isInfinite(delta) ? 0f : delta * 60f;
state.updateId ++;
state.teams.updateTeamStats();
MapPreviewLoader.checkPreviews();
if(state.rules.fog){
fogControl.update();
}
if(state.isCampaign()){
state.rules.sector.info.update();
@@ -387,17 +450,30 @@ public class Logic implements ApplicationListener{
}
Time.update();
logicVars.update();
//weather is serverside
if(!net.client() && !state.isEditor()){
updateWeather();
for(TeamData data : state.teams.getActive()){
if(data.hasAI()){
data.ai.update();
//does not work on PvP so built-in attack maps can have it on by default without issues
if(data.team.rules().buildAi && !state.rules.pvp){
if(data.buildAi == null) data.buildAi = new BaseBuilderAI(data);
data.buildAi.update();
}
if(data.team.rules().rtsAi){
if(data.rtsAi == null) data.rtsAi = new RtsAI(data);
data.rtsAi.update();
}
}
}
if(!state.isEditor()){
state.rules.objectives.update();
}
if(state.rules.waves && state.rules.waveTimer && !state.gameOver){
if(!isWaitingWave()){
state.wavetime = Math.max(state.wavetime - Time.delta, 0);
@@ -410,14 +486,19 @@ public class Logic implements ApplicationListener{
//apply weather attributes
state.envAttrs.clear();
state.envAttrs.add(state.rules.attributes);
Groups.weather.each(w -> state.envAttrs.add(w.weather.attrs, w.opacity));
Groups.update();
Events.fire(Trigger.afterGameUpdate);
}
if(!net.client() && !world.isInvalidMap() && !state.isEditor() && state.rules.canGameOver){
if(runStateCheck){
checkGameState();
}
}else if(netServer.isWaitingForPlayers() && runStateCheck){
checkGameState();
}
}
+289 -216
View File
@@ -1,6 +1,7 @@
package mindustry.core;
import arc.*;
import arc.audio.*;
import arc.func.*;
import arc.graphics.*;
import arc.math.*;
@@ -9,31 +10,36 @@ import arc.util.*;
import arc.util.CommandHandler.*;
import arc.util.io.*;
import arc.util.serialization.*;
import arc.util.serialization.JsonValue.*;
import mindustry.*;
import mindustry.annotations.Annotations.*;
import mindustry.core.GameState.*;
import mindustry.entities.*;
import mindustry.entities.units.*;
import mindustry.game.EventType.*;
import mindustry.game.*;
import mindustry.game.Teams.*;
import mindustry.gen.*;
import mindustry.io.*;
import mindustry.logic.*;
import mindustry.net.Administration.*;
import mindustry.net.Net.*;
import mindustry.net.*;
import mindustry.net.Packets.*;
import mindustry.ui.*;
import mindustry.world.*;
import mindustry.world.modules.*;
import java.io.*;
import java.util.*;
import java.util.zip.*;
import static mindustry.Vars.*;
public class NetClient implements ApplicationListener{
private static final float dataTimeout = 60 * 18;
private static final float playerSyncTime = 2;
public static final float viewScale = 2f;
private static final long entitySnapshotTimeout = 1000 * 20;
private static final float dataTimeout = 60 * 30;
/** ticks between syncs, e.g. 5 means 60/5 = 12 syncs/sec*/
private static final float playerSyncTime = 4;
private static final Reads dataReads = new Reads(null);
private static final JsonValue tmpJsonMap = new JsonValue(ValueType.object);
private long ping;
private Interval timer = new Interval(5);
@@ -45,6 +51,8 @@ public class NetClient implements ApplicationListener{
private boolean quietReset = false;
/** Counter for data timeout. */
private float timeoutTime = 0f;
/** Timestamp for last UDP state snapshot received. */
private long lastSnapshotTimestamp;
/** Last sent client snapshot ID. */
private int lastSent;
@@ -55,16 +63,25 @@ public class NetClient implements ApplicationListener{
private DataInputStream dataStream = new DataInputStream(byteStream);
/** Packet handlers for custom types of messages. */
private ObjectMap<String, Seq<Cons<String>>> customPacketHandlers = new ObjectMap<>();
/** Packet handlers for custom types of messages, in binary. */
private ObjectMap<String, Seq<Cons<byte[]>>> customBinaryPacketHandlers = new ObjectMap<>();
public NetClient(){
net.handleClient(Connect.class, packet -> {
Log.info("Connecting to server: @", packet.addressTCP);
player.admin(false);
player.admin = false;
reset();
//connection after reset
if(!net.client()){
Log.info("Connection canceled.");
disconnectQuietly();
return;
}
ui.loadfrag.hide();
ui.loadfrag.show("@connecting.data");
@@ -73,12 +90,18 @@ public class NetClient implements ApplicationListener{
disconnectQuietly();
});
ConnectPacket c = new ConnectPacket();
String locale = Core.settings.getString("locale");
if(locale.equals("default")){
locale = Locale.getDefault().toString();
}
var c = new ConnectPacket();
c.name = player.name;
c.locale = locale;
c.mods = mods.getModStrings();
c.mobile = mobile;
c.versionType = Version.type;
c.color = player.color().rgba();
c.color = player.color.rgba();
c.usid = getUsid(packet.addressTCP);
c.uuid = platform.getUUID();
@@ -89,7 +112,7 @@ public class NetClient implements ApplicationListener{
return;
}
net.send(c, SendMode.tcp);
net.send(c, true);
});
net.handleClient(Disconnect.class, packet -> {
@@ -98,19 +121,19 @@ public class NetClient implements ApplicationListener{
connecting = false;
logic.reset();
platform.updateRPC();
player.name(Core.settings.getString("name"));
player.color().set(Core.settings.getInt("color-0"));
player.name = Core.settings.getString("name");
player.color.set(Core.settings.getInt("color-0"));
if(quiet) return;
Time.runTask(3f, ui.loadfrag::hide);
if(packet.reason != null){
switch(packet.reason){
case "closed" -> ui.showSmall("@disconnect", "@disconnect.closed");
case "timeout" -> ui.showSmall("@disconnect", "@disconnect.timeout");
case "error" -> ui.showSmall("@disconnect", "@disconnect.error");
}
ui.showSmall(switch(packet.reason){
case "closed" -> "@disconnect.closed";
case "timeout" -> "@disconnect.timeout";
default -> "@disconnect.error";
}, "@disconnect.closed");
}else{
ui.showErrorMessage("@disconnect");
}
@@ -122,10 +145,6 @@ public class NetClient implements ApplicationListener{
finishConnecting();
});
net.handleClient(InvokePacket.class, packet -> {
RemoteReadClient.readPacket(packet.reader(), packet.type);
});
}
public void addPacketHandler(String type, Cons<String> handler){
@@ -136,10 +155,34 @@ public class NetClient implements ApplicationListener{
return customPacketHandlers.get(type, Seq::new);
}
public void addBinaryPacketHandler(String type, Cons<byte[]> handler){
customBinaryPacketHandlers.get(type, Seq::new).add(handler);
}
public Seq<Cons<byte[]>> getBinaryPacketHandlers(String type){
return customBinaryPacketHandlers.get(type, Seq::new);
}
@Remote(targets = Loc.server, variants = Variant.both)
public static void clientBinaryPacketReliable(String type, byte[] contents){
var arr = netClient.customBinaryPacketHandlers.get(type);
if(arr != null){
for(var c : arr){
c.get(contents);
}
}
}
@Remote(targets = Loc.server, variants = Variant.both, unreliable = true)
public static void clientBinaryPacketUnreliable(String type, byte[] contents){
clientBinaryPacketReliable(type, contents);
}
@Remote(targets = Loc.server, variants = Variant.both)
public static void clientPacketReliable(String type, String contents){
if(netClient.customPacketHandlers.containsKey(type)){
for(Cons<String> c : netClient.customPacketHandlers.get(type)){
var arr = netClient.customPacketHandlers.get(type);
if(arr != null){
for(Cons<String> c : arr){
c.get(contents);
}
}
@@ -150,16 +193,52 @@ public class NetClient implements ApplicationListener{
clientPacketReliable(type, contents);
}
//called on all clients
@Remote(variants = Variant.both, unreliable = true, called = Loc.server)
public static void sound(Sound sound, float volume, float pitch, float pan){
if(sound == null || headless) return;
sound.play(Mathf.clamp(volume, 0, 8f) * Core.settings.getInt("sfxvol") / 100f, Mathf.clamp(pitch, 0f, 20f), pan, false, false);
}
@Remote(variants = Variant.both, unreliable = true, called = Loc.server)
public static void soundAt(Sound sound, float x, float y, float volume, float pitch){
if(sound == null || headless) return;
sound.at(x, y, Mathf.clamp(pitch, 0f, 20f), Mathf.clamp(volume, 0, 4f));
}
@Remote(variants = Variant.both, unreliable = true)
public static void effect(Effect effect, float x, float y, float rotation, Color color){
if(effect == null) return;
effect.at(x, y, rotation, color);
}
@Remote(variants = Variant.both, unreliable = true)
public static void effect(Effect effect, float x, float y, float rotation, Color color, Object data){
if(effect == null) return;
effect.at(x, y, rotation, color, data);
}
@Remote(variants = Variant.both)
public static void effectReliable(Effect effect, float x, float y, float rotation, Color color){
effect(effect, x, y, rotation, color);
}
@Remote(targets = Loc.server, variants = Variant.both)
public static void sendMessage(String message, String sender, Player playersender){
public static void sendMessage(String message, @Nullable String unformatted, @Nullable Player playersender){
if(Vars.ui != null){
Vars.ui.chatfrag.addMessage(message, sender);
Vars.ui.chatfrag.addMessage(message);
Sounds.chatMessage.play();
}
if(playersender != null){
playersender.lastText(message);
if(playersender != null && unformatted != null){
//display raw unformatted text above player head
playersender.lastText(unformatted);
playersender.textFadeTime(1f);
Events.fire(new PlayerChatEvent(playersender, unformatted));
}
}
@@ -167,76 +246,83 @@ public class NetClient implements ApplicationListener{
@Remote(called = Loc.server, targets = Loc.server)
public static void sendMessage(String message){
if(Vars.ui != null){
Vars.ui.chatfrag.addMessage(message, null);
Vars.ui.chatfrag.addMessage(message);
Sounds.chatMessage.play();
}
}
//called when a server receives a chat message from a player
@Remote(called = Loc.server, targets = Loc.client)
public static void sendChatMessage(Player player, String message){
//do not receive chat messages from clients that are too young or not registered
if(net.server() && player != null && player.con != null && (Time.timeSinceMillis(player.con.connectTime) < 500 || !player.con.hasConnected || !player.isAdded())) return;
//detect and kick for foul play
if(player != null && player.con != null && !player.con.chatRate.allow(2000, Config.chatSpamLimit.num())){
player.con.kick(KickReason.kick);
netServer.admins.blacklistDos(player.con.address);
return;
}
if(message == null) return;
if(message.length() > maxTextLength){
throw new ValidateException(player, "Player has sent a message above the text limit.");
}
message = message.replace("\n", "");
Events.fire(new PlayerChatEvent(player, message));
//log commands before they are handled
if(message.startsWith(netServer.clientCommands.getPrefix())){
//log with brackets
Log.info("<&fi@: @&fr>", "&lk" + player.plainName(), "&lw" + message);
}
//check if it's a command
CommandResponse response = netServer.clientCommands.handleMessage(message, player);
if(response.type == ResponseType.noCommand){ //no command to handle
message = netServer.admins.filterMessage(player, message);
//supress chat message if it's filtered out
//suppress chat message if it's filtered out
if(message == null){
return;
}
//special case; graphical server needs to see its message
if(!headless){
sendMessage(message, colorizeName(player.id(), player.name), player);
sendMessage(netServer.chatFormatter.format(player, message), message, player);
}
//server console logging
Log.info("&fi@: @", "&lc" + player.name, "&lw" + message);
Log.info("&fi@: @", "&lc" + player.plainName(), "&lw" + message);
//invoke event for all clients but also locally
//this is required so other clients get the correct name even if they don't know who's sending it yet
Call.sendMessage(message, colorizeName(player.id(), player.name), player);
Call.sendMessage(netServer.chatFormatter.format(player, message), message, player);
}else{
//log command to console but with brackets
Log.info("<&fi@: @&fr>", "&lk" + player.name, "&lw" + message);
//a command was sent, now get the output
if(response.type != ResponseType.valid){
String text;
//send usage
if(response.type == ResponseType.manyArguments){
text = "[scarlet]Too many arguments. Usage:[lightgray] " + response.command.text + "[gray] " + response.command.paramText;
}else if(response.type == ResponseType.fewArguments){
text = "[scarlet]Too few arguments. Usage:[lightgray] " + response.command.text + "[gray] " + response.command.paramText;
}else{ //unknown command
text = "[scarlet]Unknown command. Check [lightgray]/help[scarlet].";
String text = netServer.invalidHandler.handle(player, response);
if(text != null){
player.sendMessage(text);
}
player.sendMessage(text);
}
}
}
public static String colorizeName(int id, String name){
Player player = Groups.player.getByID(id);
if(name == null || player == null) return null;
return "[#" + player.color().toString().toUpperCase() + "]" + name;
}
@Remote(called = Loc.client, variants = Variant.one)
public static void connect(String ip, int port){
if(!steam && ip.startsWith("steam:")) return;
netClient.disconnectQuietly();
logic.reset();
ui.join.connect(ip, port);
}
@Remote(targets = Loc.client)
@Remote(targets = Loc.client, priority = PacketPriority.high)
public static void ping(Player player, long time){
Call.pingResponse(player.con, time);
}
@@ -257,7 +343,7 @@ public class NetClient implements ApplicationListener{
public static void kick(KickReason reason){
netClient.disconnectQuietly();
logic.reset();
if(reason == KickReason.serverRestarting){
ui.join.reconnect();
return;
@@ -281,83 +367,42 @@ public class NetClient implements ApplicationListener{
ui.loadfrag.hide();
}
@Remote(variants = Variant.both, unreliable = true)
public static void setHudText(String message){
if(message == null) return;
ui.hudfrag.setHudText(message);
}
@Remote(variants = Variant.both)
public static void hideHudText(){
ui.hudfrag.toggleHudText(false);
}
/** TCP version */
@Remote(variants = Variant.both)
public static void setHudTextReliable(String message){
setHudText(message);
}
@Remote(variants = Variant.both)
public static void announce(String message){
if(message == null) return;
ui.announce(message);
}
@Remote(variants = Variant.both)
public static void infoMessage(String message){
if(message == null) return;
ui.showText("", message);
}
@Remote(variants = Variant.both)
public static void infoPopup(String message, float duration, int align, int top, int left, int bottom, int right){
if(message == null) return;
ui.showInfoPopup(message, duration, align, top, left, bottom, right);
}
@Remote(variants = Variant.both)
public static void label(String message, float duration, float worldx, float worldy){
if(message == null) return;
ui.showLabel(message, duration, worldx, worldy);
}
@Remote(variants = Variant.both, unreliable = true)
public static void effect(Effect effect, float x, float y, float rotation, Color color){
if(effect == null) return;
effect.at(x, y, rotation, color);
}
@Remote(variants = Variant.both)
public static void effectReliable(Effect effect, float x, float y, float rotation, Color color){
effect(effect, x, y, rotation, color);
}
@Remote(variants = Variant.both)
public static void infoToast(String message, float duration){
if(message == null) return;
ui.showInfoToast(message, duration);
}
@Remote(variants = Variant.both)
public static void warningToast(int unicode, String text){
if(text == null || Fonts.icon.getData().getGlyph((char)unicode) == null) return;
ui.hudfrag.showToast(Fonts.getGlyph(Fonts.icon, (char)unicode), text);
}
@Remote(variants = Variant.both)
public static void setRules(Rules rules){
state.rules = rules;
}
@Remote(variants = Variant.both)
public static void setRule(String rule, String jsonData){
try{
//readField searches for the specified value, so create a fake parent for it.
tmpJsonMap.child = null;
tmpJsonMap.addChild(rule, new JsonReader().parse(jsonData));
JsonIO.json.readField(state.rules, rule, tmpJsonMap);
}catch(Throwable error){
Log.err("Failed to read rule", error);
}
}
//NOTE: avoid using this, runs into packet/buffer size limitations
@Remote(variants = Variant.both)
public static void setObjectives(MapObjectives executor){
state.rules.objectives = executor;
}
@Remote(variants = Variant.both, called = Loc.server)
public static void clearObjectives(){
state.rules.objectives.clear();
}
@Remote(variants = Variant.both, called = Loc.server)
public static void completeObjective(int index){
var obj = state.rules.objectives.get(index);
if(obj != null){
obj.done();
}
}
@Remote(variants = Variant.both)
public static void worldDataBegin(){
Groups.clear();
@@ -382,6 +427,13 @@ public class NetClient implements ApplicationListener{
player.set(x, y);
}
@Remote(variants = Variant.both, unreliable = true)
public static void setCameraPosition(float x, float y){
if(Core.camera != null){
Core.camera.position.set(x, y);
}
}
@Remote
public static void playerDisconnect(int playerid){
if(netClient != null){
@@ -390,67 +442,88 @@ public class NetClient implements ApplicationListener{
Groups.player.removeByID(playerid);
}
public static void readSyncEntity(DataInputStream input, Reads read) throws IOException{
int id = input.readInt();
byte typeID = input.readByte();
Syncc entity = Groups.sync.getByID(id);
boolean add = false, created = false;
if(entity == null && id == player.id()){
entity = player;
add = true;
}
//entity must not be added yet, so create it
if(entity == null){
entity = (Syncc)EntityMapping.map(typeID & 0xFF).get();
entity.id(id);
if(!netClient.isEntityUsed(entity.id())){
add = true;
}
created = true;
}
//read the entity
entity.readSync(read);
if(created){
//snap initial starting position
entity.snapSync();
}
if(add){
entity.add();
netClient.addRemovedEntity(entity.id());
}
}
@Remote(variants = Variant.one, priority = PacketPriority.low, unreliable = true)
public static void entitySnapshot(short amount, short dataLen, byte[] data){
public static void entitySnapshot(short amount, byte[] data){
try{
netClient.byteStream.setBytes(net.decompressSnapshot(data, dataLen));
netClient.lastSnapshotTimestamp = Time.millis();
netClient.byteStream.setBytes(data);
DataInputStream input = netClient.dataStream;
//go through each entity
for(int j = 0; j < amount; j++){
int id = input.readInt();
byte typeID = input.readByte();
Syncc entity = Groups.sync.getByID(id);
boolean add = false, created = false;
if(entity == null && id == player.id()){
entity = player;
add = true;
}
//entity must not be added yet, so create it
if(entity == null){
entity = (Syncc)EntityMapping.map(typeID).get();
entity.id(id);
if(!netClient.isEntityUsed(entity.id())){
add = true;
}
created = true;
}
//read the entity
entity.readSync(Reads.get(input));
if(created){
//snap initial starting position
entity.snapSync();
}
if(add){
entity.add();
netClient.addRemovedEntity(entity.id());
}
readSyncEntity(input, Reads.get(input));
}
}catch(Exception e){
//don't disconnect, just log it
Log.err("Error reading entity snapshot", e);
}
}
@Remote(variants = Variant.one, priority = PacketPriority.low, unreliable = true)
public static void hiddenSnapshot(IntSeq ids){
for(int i = 0; i < ids.size; i++){
int id = ids.items[i];
var entity = Groups.sync.getByID(id);
if(entity != null){
entity.handleSyncHidden();
}
}catch(IOException e){
throw new RuntimeException(e);
}
}
@Remote(variants = Variant.both, priority = PacketPriority.low, unreliable = true)
public static void blockSnapshot(short amount, short dataLen, byte[] data){
public static void blockSnapshot(short amount, byte[] data){
try{
netClient.byteStream.setBytes(net.decompressSnapshot(data, dataLen));
netClient.byteStream.setBytes(data);
DataInputStream input = netClient.dataStream;
for(int i = 0; i < amount; i++){
int pos = input.readInt();
short block = input.readShort();
Tile tile = world.tile(pos);
if(tile == null || tile.build == null){
Log.warn("Missing entity at @. Skipping block snapshot.", tile);
break;
}
tile.build.readAll(Reads.get(input), tile.build.version());
if(tile.build.block.id != block){
Log.warn("Block ID mismatch at @: @ != @. Skipping block snapshot.", tile, tile.build.block.id, block);
break;
}
tile.build.readSync(Reads.get(input), tile.build.version());
}
}catch(Exception e){
Log.err(e);
@@ -458,7 +531,7 @@ public class NetClient implements ApplicationListener{
}
@Remote(variants = Variant.one, priority = PacketPriority.low, unreliable = true)
public static void stateSnapshot(float waveTime, int wave, int enemies, boolean paused, boolean gameOver, int timeData, short coreDataLen, byte[] coreData){
public static void stateSnapshot(float waveTime, int wave, int enemies, boolean paused, boolean gameOver, int timeData, byte tps, long rand0, long rand1, byte[] coreData){
try{
if(wave > state.wave){
state.wave = wave;
@@ -469,22 +542,30 @@ public class NetClient implements ApplicationListener{
state.wavetime = waveTime;
state.wave = wave;
state.enemies = enemies;
state.serverPaused = paused;
if(!state.isMenu()){
state.set(paused ? State.paused : State.playing);
}
state.serverTps = tps & 0xff;
//note that this is far from a guarantee that random state is synced - tiny changes in delta and ping can throw everything off again.
//syncing will only make much of a difference when rand() is called infrequently
GlobalVars.rand.seed0 = rand0;
GlobalVars.rand.seed1 = rand1;
universe.updateNetSeconds(timeData);
netClient.byteStream.setBytes(net.decompressSnapshot(coreData, coreDataLen));
netClient.byteStream.setBytes(coreData);
DataInputStream input = netClient.dataStream;
dataReads.input = input;
byte cores = input.readByte();
for(int i = 0; i < cores; i++){
int pos = input.readInt();
Tile tile = world.tile(pos);
if(tile != null && tile.build != null){
tile.build.items.read(Reads.get(input));
int teams = input.readUnsignedByte();
for(int i = 0; i < teams; i++){
int team = input.readUnsignedByte();
TeamData data = Team.all[team].data();
if(data.cores.any()){
data.cores.first().items.read(dataReads);
}else{
new ItemModule().read(Reads.get(input));
new ItemModule().read(dataReads);
}
}
@@ -498,7 +579,18 @@ public class NetClient implements ApplicationListener{
if(!net.client()) return;
if(state.isGame()){
if(!connecting) sync();
if(!connecting){
sync();
//timeout if UDP snapshot packets are not received for a while
if(lastSnapshotTimestamp > 0 && Time.timeSinceMillis(lastSnapshotTimestamp) > entitySnapshotTimeout){
Log.err("Timed out after not received UDP snapshots.");
quiet = true;
ui.showErrorMessage("@disconnect.snapshottimeout");
net.disconnect();
lastSnapshotTimestamp = 0;
}
}
}else if(!connecting){
net.disconnect();
}else{ //...must be connecting
@@ -514,6 +606,11 @@ public class NetClient implements ApplicationListener{
}
}
/** Resets the world data timeout counter. */
public void resetTimeout(){
timeoutTime = 0f;
}
public boolean isConnecting(){
return connecting;
}
@@ -530,6 +627,7 @@ public class NetClient implements ApplicationListener{
Core.app.post(Call::connectConfirm);
Time.runTask(40f, platform::updateRPC);
Core.app.post(ui.loadfrag::hide);
lastSnapshotTimestamp = Time.millis();
}
private void reset(){
@@ -540,6 +638,7 @@ public class NetClient implements ApplicationListener{
quietReset = false;
quiet = false;
lastSent = 0;
lastSnapshotTimestamp = 0;
Groups.clear();
ui.chatfrag.clearMessages();
@@ -581,50 +680,24 @@ public class NetClient implements ApplicationListener{
void sync(){
if(timer.get(0, playerSyncTime)){
BuildPlan[] requests = null;
if(player.isBuilder()){
//limit to 10 to prevent buffer overflows
int usedRequests = Math.min(player.unit().plans().size, 10);
int totalLength = 0;
//prevent buffer overflow by checking config length
for(int i = 0; i < usedRequests; i++){
BuildPlan plan = player.unit().plans().get(i);
if(plan.config instanceof byte[] b){
int length = b.length;
totalLength += length;
}
if(totalLength > 1024){
usedRequests = i + 1;
break;
}
}
requests = new BuildPlan[usedRequests];
for(int i = 0; i < usedRequests; i++){
requests[i] = player.unit().plans().get(i);
}
}
Unit unit = player.dead() ? Nulls.unit : player.unit();
int uid = player.dead() ? -1 : unit.id;
boolean dead = player.dead();
Unit unit = dead ? null : player.unit();
int uid = dead || unit == null ? -1 : unit.id;
Call.clientSnapshot(
lastSent++,
uid,
player.dead(),
unit.x, unit.y,
player.unit().aimX(), player.unit().aimY(),
unit.rotation,
dead,
dead ? player.x : unit.x, dead ? player.y : unit.y,
dead ? 0f : unit.aimX(), dead ? 0f : unit.aimY(),
unit == null ? 0f : unit.rotation,
unit instanceof Mechc m ? m.baseRotation() : 0,
unit.vel.x, unit.vel.y,
player.unit().mineTile,
unit == null ? 0f : unit.vel.x, unit == null ? 0f : unit.vel.y,
dead ? null : unit.mineTile,
player.boosting, player.shooting, ui.chatfrag.shown(), control.input.isBuilding,
requests,
player.isBuilder() && unit != null ? unit.plans : null,
Core.camera.position.x, Core.camera.position.y,
Core.camera.width * viewScale, Core.camera.height * viewScale
Core.camera.width, Core.camera.height
);
}
+405 -199
View File
@@ -9,21 +9,20 @@ import arc.struct.*;
import arc.util.*;
import arc.util.CommandHandler.*;
import arc.util.io.*;
import arc.util.serialization.*;
import mindustry.annotations.Annotations.*;
import mindustry.content.*;
import mindustry.core.GameState.*;
import mindustry.entities.units.*;
import mindustry.game.EventType.*;
import mindustry.game.*;
import mindustry.game.EventType.*;
import mindustry.game.Teams.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.logic.*;
import mindustry.net.*;
import mindustry.net.Administration.*;
import mindustry.net.Packets.*;
import mindustry.world.*;
import mindustry.world.blocks.storage.CoreBlock.*;
import java.io.*;
import java.net.*;
@@ -35,21 +34,24 @@ import static mindustry.Vars.*;
public class NetServer implements ApplicationListener{
/** note that snapshots are compressed, so the max snapshot size here is above the typical UDP safe limit */
private static final int maxSnapshotSize = 800, timerBlockSync = 0;
private static final float serverSyncTime = 12, blockSyncTime = 60 * 6;
private static final int maxSnapshotSize = 800;
private static final int timerBlockSync = 0, timerHealthSync = 1;
private static final float blockSyncTime = 60 * 6, healthSyncTime = 30;
private static final FloatBuffer fbuffer = FloatBuffer.allocate(20);
private static final Writes dataWrites = new Writes(null);
private static final IntSeq hiddenIds = new IntSeq();
private static final IntSeq healthSeq = new IntSeq(maxSnapshotSize / 4 + 1);
private static final Vec2 vector = new Vec2();
private static final Rect viewport = new Rect();
/** If a player goes away of their server-side coordinates by this distance, they get teleported back. */
private static final float correctDist = tilesize * 12f;
private static final float correctDist = tilesize * 14f;
public final Administration admins = new Administration();
public final CommandHandler clientCommands = new CommandHandler("/");
public Administration admins = new Administration();
public CommandHandler clientCommands = new CommandHandler("/");
public TeamAssigner assigner = (player, players) -> {
if(state.rules.pvp){
//find team with minimum amount of players and auto-assign player to that.
TeamData re = state.teams.getActive().min(data -> {
if((state.rules.waveTeam == data.team && state.rules.waves) || !data.team.active()) return Integer.MAX_VALUE;
if((state.rules.waveTeam == data.team && state.rules.waves) || !data.team.active() || data.team == Team.derelict) return Integer.MAX_VALUE;
int count = 0;
for(Player other : players){
@@ -57,16 +59,54 @@ public class NetServer implements ApplicationListener{
count++;
}
}
return count;
return (float)count + Mathf.random(-0.1f, 0.1f); //if several have the same playercount pick random
});
return re == null ? null : re.team;
}
return state.rules.defaultTeam;
};
/** Converts a message + NULLABLE player sender into a single string. Override for custom prefixes/suffixes. */
public ChatFormatter chatFormatter = (player, message) -> player == null ? message : "[coral][[" + player.coloredName() + "[coral]]:[white] " + message;
private boolean closing = false;
private Interval timer = new Interval();
/** Handles an incorrect command response. Returns text that will be sent to player. Override for customisation. */
public InvalidCommandHandler invalidHandler = (player, response) -> {
if(response.type == ResponseType.manyArguments){
return "[scarlet]Too many arguments. Usage:[lightgray] " + response.command.text + "[gray] " + response.command.paramText;
}else if(response.type == ResponseType.fewArguments){
return "[scarlet]Too few arguments. Usage:[lightgray] " + response.command.text + "[gray] " + response.command.paramText;
}else{ //unknown command
int minDst = 0;
Command closest = null;
for(Command command : netServer.clientCommands.getCommandList()){
int dst = Strings.levenshtein(command.text, response.runCommand);
if(dst < 3 && (closest == null || dst < minDst)){
minDst = dst;
closest = command;
}
}
if(closest != null){
return "[scarlet]Unknown command. Did you mean \"[lightgray]" + closest.text + "[]\"?";
}else{
return "[scarlet]Unknown command. Check [lightgray]/help[scarlet].";
}
}
};
private boolean closing = false, pvpAutoPaused = true;
private Interval timer = new Interval(10);
private IntSet buildHealthChanged = new IntSet();
/** Current kick session. */
public @Nullable VoteSession currentlyKicking = null;
/** Duration of a kick in seconds. */
public static int kickDuration = 60 * 60;
/** Voting round duration in seconds. */
public static float voteDuration = 0.5f * 60;
/** Cooldown between votes in seconds. */
public static int voteCooldown = 60 * 5;
private ReusableByteOutStream writeBuffer = new ReusableByteOutStream(127);
private Writes outputBuffer = new Writes(new DataOutputStream(writeBuffer));
@@ -77,10 +117,16 @@ public class NetServer implements ApplicationListener{
private DataOutputStream dataStream = new DataOutputStream(syncStream);
/** Packet handlers for custom types of messages. */
private ObjectMap<String, Seq<Cons2<Player, String>>> customPacketHandlers = new ObjectMap<>();
/** Packet handlers for custom types of messages - binary version. */
private ObjectMap<String, Seq<Cons2<Player, byte[]>>> customBinaryPacketHandlers = new ObjectMap<>();
/** Packet handlers for logic client data */
private ObjectMap<String, Seq<Cons2<Player, Object>>> logicClientDataHandlers = new ObjectMap<>();
public NetServer(){
net.handleServer(Connect.class, (con, connect) -> {
Events.fire(new ConnectionEvent(con));
if(admins.isIPBanned(connect.addressTCP) || admins.isSubnetBanned(connect.addressTCP)){
con.kick(KickReason.banned);
}
@@ -93,23 +139,19 @@ public class NetServer implements ApplicationListener{
});
net.handleServer(ConnectPacket.class, (con, packet) -> {
if(con.kicked) return;
if(con.address.startsWith("steam:")){
packet.uuid = con.address.substring("steam:".length());
}
String uuid = packet.uuid;
byte[] buuid = Base64Coder.decode(uuid);
CRC32 crc = new CRC32();
crc.update(buuid, 0, 8);
ByteBuffer buff = ByteBuffer.allocate(8);
buff.put(buuid, 8, 8);
buff.position(0);
if(crc.getValue() != buff.getLong()){
con.kick(KickReason.clientOutdated);
return;
}
Events.fire(new ConnectPacketEvent(con, packet));
if(admins.isIPBanned(con.address) || admins.isSubnetBanned(con.address)) return;
con.connectTime = Time.millis();
String uuid = packet.uuid;
if(admins.isIPBanned(con.address) || admins.isSubnetBanned(con.address) || con.kicked || !con.isConnected()) return;
if(con.hasBegunConnecting){
con.kick(KickReason.idInUse);
@@ -155,7 +197,8 @@ public class NetServer implements ApplicationListener{
if(!extraMods.isEmpty()){
result.append("Unnecessary mods:[lightgray]\n").append("> ").append(extraMods.toString("\n> "));
}
con.kick(result.toString());
con.kick(result.toString(), 0);
return;
}
if(!admins.isWhitelisted(packet.uuid, packet.usid)){
@@ -164,7 +207,7 @@ public class NetServer implements ApplicationListener{
info.id = packet.uuid;
admins.save();
Call.infoMessage(con, "You are not whitelisted here.");
info("&lcDo &lywhitelist-add @&lc to whitelist the player &lb'@'", packet.uuid, packet.name);
info("&lcDo &lywhitelist add @&lc to whitelist the player &lb'@'", packet.uuid, packet.name);
con.kick(KickReason.whitelist);
return;
}
@@ -177,15 +220,24 @@ public class NetServer implements ApplicationListener{
boolean preventDuplicates = headless && netServer.admins.isStrict();
if(preventDuplicates){
if(Groups.player.contains(p -> p.name.trim().equalsIgnoreCase(packet.name.trim()))){
if(Groups.player.contains(p -> Strings.stripColors(p.name).trim().equalsIgnoreCase(Strings.stripColors(packet.name).trim()))){
con.kick(KickReason.nameInUse);
return;
}
if(Groups.player.contains(player -> player.uuid().equals(packet.uuid) || player.usid().equals(packet.usid))){
con.uuid = packet.uuid;
con.kick(KickReason.idInUse);
return;
}
for(var otherCon : net.getConnections()){
if(otherCon != con && uuid.equals(otherCon.uuid)){
con.uuid = packet.uuid;
con.kick(KickReason.idInUse);
return;
}
}
}
packet.name = fixName(packet.name);
@@ -195,6 +247,10 @@ public class NetServer implements ApplicationListener{
return;
}
if(packet.locale == null){
packet.locale = "en";
}
String ip = con.address;
admins.updatePlayerJoined(uuid, ip, packet.name);
@@ -215,6 +271,7 @@ public class NetServer implements ApplicationListener{
player.con.uuid = uuid;
player.con.mobile = packet.mobile;
player.name = packet.name;
player.locale = packet.locale;
player.color.set(packet.color).a(1f);
//save admin ID but don't overwrite it
@@ -243,21 +300,6 @@ public class NetServer implements ApplicationListener{
Events.fire(new PlayerConnect(player));
});
net.handleServer(InvokePacket.class, (con, packet) -> {
if(con.player == null) return;
try{
RemoteReadServer.readPacket(packet.reader(), packet.type, con.player);
}catch(ValidateException e){
debug("Validation failed for '@': @", e.player, e.getMessage());
}catch(RuntimeException e){
if(e.getCause() instanceof ValidateException v){
debug("Validation failed for '@': @", v.player, v.getMessage());
}else{
throw e;
}
}
});
registerCommands();
}
@@ -276,7 +318,7 @@ public class NetServer implements ApplicationListener{
int page = args.length > 0 ? Strings.parseInt(args[0]) : 1;
int pages = Mathf.ceil((float)clientCommands.getCommandList().size / commandsPerPage);
page --;
page--;
if(page >= pages || page < 0){
player.sendMessage("[scarlet]'page' must be a number between[orange] 1[] and[orange] " + pages + "[scarlet].");
@@ -296,74 +338,25 @@ public class NetServer implements ApplicationListener{
clientCommands.<Player>register("t", "<message...>", "Send a message only to your teammates.", (args, player) -> {
String message = admins.filterMessage(player, args[0]);
if(message != null){
Groups.player.each(p -> p.team() == player.team(), o -> o.sendMessage(message, player, "[#" + player.team().color.toString() + "]<T>" + NetClient.colorizeName(player.id(), player.name)));
String raw = "[#" + player.team().color.toString() + "]<T> " + chatFormatter.format(player, message);
Groups.player.each(p -> p.team() == player.team(), o -> o.sendMessage(raw, player, message));
}
});
clientCommands.<Player>register("a", "<message...>", "Send a message only to admins.", (args, player) -> {
if(!player.admin){
player.sendMessage("[scarlet]You must be admin to use this command.");
player.sendMessage("[scarlet]You must be an admin to use this command.");
return;
}
Groups.player.each(Player::admin, a -> a.sendMessage(args[0], player, "[#" + Pal.adminChat.toString() + "]<A>" + NetClient.colorizeName(player.id, player.name)));
String raw = "[#" + Pal.adminChat.toString() + "]<A> " + chatFormatter.format(player, args[0]);
Groups.player.each(Player::admin, a -> a.sendMessage(raw, player, args[0]));
});
//duration of a a kick in seconds
int kickDuration = 60 * 60;
//voting round duration in seconds
float voteDuration = 0.5f * 60;
//cooldown between votes in seconds
int voteCooldown = 60 * 5;
class VoteSession{
Player target;
ObjectSet<String> voted = new ObjectSet<>();
VoteSession[] map;
Timer.Task task;
int votes;
public VoteSession(VoteSession[] map, Player target){
this.target = target;
this.map = map;
this.task = Timer.schedule(() -> {
if(!checkPass()){
Call.sendMessage(Strings.format("[lightgray]Vote failed. Not enough votes to kick[orange] @[lightgray].", target.name));
map[0] = null;
task.cancel();
}
}, voteDuration);
}
void vote(Player player, int d){
votes += d;
voted.addAll(player.uuid(), admins.getInfo(player.uuid()).lastIP);
Call.sendMessage(Strings.format("[lightgray]@[lightgray] has voted on kicking[orange] @[].[accent] (@/@)\n[lightgray]Type[orange] /vote <y/n>[] to agree.",
player.name, target.name, votes, votesRequired()));
checkPass();
}
boolean checkPass(){
if(votes >= votesRequired()){
Call.sendMessage(Strings.format("[orange]Vote passed.[scarlet] @[orange] will be banned from the server for @ minutes.", target.name, (kickDuration / 60)));
target.getInfo().lastKicked = Time.millis() + kickDuration * 1000;
Groups.player.each(p -> p.uuid().equals(target.uuid()), p -> p.kick(KickReason.vote));
map[0] = null;
task.cancel();
return true;
}
return false;
}
}
//cooldowns per player
ObjectMap<String, Timekeeper> cooldowns = new ObjectMap<>();
//current kick sessions
VoteSession[] currentlyKicking = {null};
clientCommands.<Player>register("votekick", "[player...]", "Vote to kick a player.", (args, player) -> {
clientCommands.<Player>register("votekick", "[player] [reason...]", "Vote to kick a player with a valid reason.", (args, player) -> {
if(!Config.enableVotekick.bool()){
player.sendMessage("[scarlet]Vote-kick is disabled on this server.");
return;
@@ -379,7 +372,7 @@ public class NetServer implements ApplicationListener{
return;
}
if(currentlyKicking[0] != null){
if(currentlyKicking != null){
player.sendMessage("[scarlet]A vote is already in progress.");
return;
}
@@ -392,6 +385,8 @@ public class NetServer implements ApplicationListener{
builder.append("[lightgray] ").append(p.name).append("[accent] (#").append(p.id()).append(")\n");
});
player.sendMessage(builder.toString());
}else if(args.length == 1){
player.sendMessage("[orange]You need a valid reason to kick the player. Add a reason after the player name.");
}else{
Player found;
if(args[0].length() > 1 && args[0].startsWith("#") && Strings.canParseInt(args[0].substring(1))){
@@ -402,7 +397,9 @@ public class NetServer implements ApplicationListener{
}
if(found != null){
if(found.admin){
if(found == player){
player.sendMessage("[scarlet]You can't vote to kick yourself.");
}else if(found.admin){
player.sendMessage("[scarlet]Did you really expect to be able to kick an admin?");
}else if(found.isLocal()){
player.sendMessage("[scarlet]Local players cannot be kicked.");
@@ -416,10 +413,11 @@ public class NetServer implements ApplicationListener{
return;
}
VoteSession session = new VoteSession(currentlyKicking, found);
VoteSession session = new VoteSession(found);
session.vote(player, 1);
Call.sendMessage(Strings.format("[lightgray]Reason:[orange] @[lightgray].", args[1]));
vtime.reset();
currentlyKicking[0] = session;
currentlyKicking = session;
}
}else{
player.sendMessage("[scarlet]No player [orange]'" + args[0] + "'[scarlet] found.");
@@ -427,38 +425,50 @@ public class NetServer implements ApplicationListener{
}
});
clientCommands.<Player>register("vote", "<y/n>", "Vote to kick the current player.", (arg, player) -> {
if(currentlyKicking[0] == null){
clientCommands.<Player>register("vote", "<y/n/c>", "Vote to kick the current player. Admins can cancel the voting with 'c'.", (arg, player) -> {
if(currentlyKicking == null){
player.sendMessage("[scarlet]Nobody is being voted on.");
}else{
if(player.isLocal()){
player.sendMessage("Local players can't vote. Kick the player yourself instead.");
if(player.admin && arg[0].equalsIgnoreCase("c")){
Call.sendMessage(Strings.format("[lightgray]Vote canceled by admin[orange] @[lightgray].", player.name));
currentlyKicking.task.cancel();
currentlyKicking = null;
return;
}
if(player.isLocal()){
player.sendMessage("[scarlet]Local players can't vote. Kick the player yourself instead.");
return;
}
int sign = switch(arg[0].toLowerCase()){
case "y", "yes" -> 1;
case "n", "no" -> -1;
default -> 0;
};
//hosts can vote all they want
if((currentlyKicking[0].voted.contains(player.uuid()) || currentlyKicking[0].voted.contains(admins.getInfo(player.uuid()).lastIP))){
player.sendMessage("[scarlet]You've already voted. Sit down.");
if((currentlyKicking.voted.get(player.uuid(), 2) == sign || currentlyKicking.voted.get(admins.getInfo(player.uuid()).lastIP, 2) == sign)){
player.sendMessage(Strings.format("[scarlet]You've already voted @. Sit down.", arg[0].toLowerCase()));
return;
}
if(currentlyKicking[0].target == player){
if(currentlyKicking.target == player){
player.sendMessage("[scarlet]You can't vote on your own trial.");
return;
}
if(currentlyKicking[0].target.team() != player.team()){
if(currentlyKicking.target.team() != player.team()){
player.sendMessage("[scarlet]You can't vote for other teams.");
return;
}
if(!arg[0].equalsIgnoreCase("y") && !arg[0].equalsIgnoreCase("n")){
if(sign == 0){
player.sendMessage("[scarlet]Vote either 'y' (yes) or 'n' (no).");
return;
}
int sign = arg[0].equalsIgnoreCase("y") ? 1 : -1;
currentlyKicking[0].vote(player, sign);
currentlyKicking.vote(player, sign);
}
});
@@ -509,6 +519,18 @@ public class NetServer implements ApplicationListener{
return customPacketHandlers.get(type, Seq::new);
}
public void addBinaryPacketHandler(String type, Cons2<Player, byte[]> handler){
customBinaryPacketHandlers.get(type, Seq::new).add(handler);
}
public Seq<Cons2<Player, byte[]>> getBinaryPacketHandlers(String type){
return customBinaryPacketHandlers.get(type, Seq::new);
}
public void addLogicDataHandler(String type, Cons2<Player, Object> handler){
logicClientDataHandlers.get(type, Seq::new).add(handler);
}
public static void onDisconnect(Player player, String reason){
//singleplayer multiplayer weirdness
if(player.con == null){
@@ -523,7 +545,7 @@ public class NetServer implements ApplicationListener{
Call.playerDisconnect(player.id());
}
String message = Strings.format("&lb@&fi&lk has disconnected. &fi&lk[&lb@&fi&lk] (@)", player.name, player.uuid(), reason);
String message = Strings.format("&lb@&fi&lk has disconnected. [&lb@&fi&lk] (@)", player.plainName(), player.uuid(), reason);
if(Config.showConnectMessages.bool()) info(message);
}
@@ -531,6 +553,38 @@ public class NetServer implements ApplicationListener{
player.con.hasDisconnected = true;
}
//these functions are for debugging only, and will be removed!
@Remote(targets = Loc.client, variants = Variant.one)
public static void requestDebugStatus(Player player){
int flags =
(player.con.hasDisconnected ? 1 : 0) |
(player.con.hasConnected ? 2 : 0) |
(player.isAdded() ? 4 : 0) |
(player.con.hasBegunConnecting ? 8 : 0);
Call.debugStatusClient(player.con, flags, player.con.lastReceivedClientSnapshot, player.con.snapshotsSent);
Call.debugStatusClientUnreliable(player.con, flags, player.con.lastReceivedClientSnapshot, player.con.snapshotsSent);
}
@Remote(variants = Variant.both, priority = PacketPriority.high)
public static void debugStatusClient(int value, int lastClientSnapshot, int snapshotsSent){
logClientStatus(true, value, lastClientSnapshot, snapshotsSent);
}
@Remote(variants = Variant.both, priority = PacketPriority.high, unreliable = true)
public static void debugStatusClientUnreliable(int value, int lastClientSnapshot, int snapshotsSent){
logClientStatus(false, value, lastClientSnapshot, snapshotsSent);
}
static void logClientStatus(boolean reliable, int value, int lastClientSnapshot, int snapshotsSent){
Log.info("@ Debug status received. disconnected = @, connected = @, added = @, begunConnecting = @ lastClientSnapshot = @, snapshotsSent = @",
reliable ? "[RELIABLE]" : "[UNRELIABLE]",
(value & 1) != 0, (value & 2) != 0, (value & 4) != 0, (value & 8) != 0,
lastClientSnapshot, snapshotsSent
);
}
@Remote(targets = Loc.client)
public static void serverPacketReliable(Player player, String type, String contents){
if(netServer.customPacketHandlers.containsKey(type)){
@@ -545,24 +599,53 @@ public class NetServer implements ApplicationListener{
serverPacketReliable(player, type, contents);
}
@Remote(targets = Loc.client)
public static void serverBinaryPacketReliable(Player player, String type, byte[] contents){
if(netServer.customPacketHandlers.containsKey(type)){
for(var c : netServer.customBinaryPacketHandlers.get(type)){
c.get(player, contents);
}
}
}
@Remote(targets = Loc.client, unreliable = true)
public static void serverBinaryPacketUnreliable(Player player, String type, byte[] contents){
serverBinaryPacketReliable(player, type, contents);
}
@Remote(targets = Loc.client)
public static void clientLogicDataReliable(Player player, String channel, Object value){
Seq<Cons2<Player, Object>> handlers = netServer.logicClientDataHandlers.get(channel);
if(handlers != null){
for(Cons2<Player, Object> handler : handlers){
handler.get(player, value);
}
}
}
@Remote(targets = Loc.client, unreliable = true)
public static void clientLogicDataUnreliable(Player player, String channel, Object value){
clientLogicDataReliable(player, channel, value);
}
private static boolean invalid(float f){
return Float.isInfinite(f) || Float.isNaN(f);
}
@Remote(targets = Loc.client, unreliable = true)
@Remote(targets = Loc.client, unreliable = true, priority = PacketPriority.high)
public static void clientSnapshot(
Player player,
int snapshotID,
int unitID,
boolean dead,
float x, float y,
float pointerX, float pointerY,
float rotation, float baseRotation,
float xVelocity, float yVelocity,
Tile mining,
boolean boosting, boolean shooting, boolean chatting, boolean building,
@Nullable BuildPlan[] requests,
float viewX, float viewY, float viewWidth, float viewHeight
Player player,
int snapshotID,
int unitID,
boolean dead,
float x, float y,
float pointerX, float pointerY,
float rotation, float baseRotation,
float xVelocity, float yVelocity,
Tile mining,
boolean boosting, boolean shooting, boolean chatting, boolean building,
@Nullable Queue<BuildPlan> plans,
float viewX, float viewY, float viewWidth, float viewHeight
){
NetConnection con = player.con;
if(con == null || snapshotID < con.lastReceivedClientSnapshot) return;
@@ -601,22 +684,21 @@ public class NetServer implements ApplicationListener{
player.shooting = shooting;
player.boosting = boosting;
player.unit().controlWeapons(shooting, shooting);
player.unit().aim(pointerX, pointerY);
@Nullable var unit = player.unit();
if(player.isBuilder()){
player.unit().clearBuilding();
player.unit().updateBuilding(building);
unit.clearBuilding();
unit.updateBuilding(building);
if(requests != null){
for(BuildPlan req : requests){
if(plans != null){
for(BuildPlan req : plans){
if(req == null) continue;
Tile tile = world.tile(req.x, req.y);
if(tile == null || (!req.breaking && req.block == null)) continue;
//auto-skip done requests
if(req.breaking && tile.block() == Blocks.air){
continue;
}else if(!req.breaking && tile.block() == req.block && (!req.block.rotate || (tile.build != null && tile.build.rotation == req.rotation))){
}else if(!req.breaking && tile.block() == req.block && tile.team() != Team.derelict && (!req.block.rotate || (tile.build != null && tile.build.rotation == req.rotation))){
continue;
}else if(con.rejectedRequests.contains(r -> r.breaking == req.breaking && r.x == req.x && r.y == req.y)){ //check if request was recently rejected, and skip it if so
continue;
@@ -635,18 +717,15 @@ public class NetServer implements ApplicationListener{
}
}
player.unit().mineTile = mining;
con.rejectedRequests.clear();
if(!player.dead()){
Unit unit = player.unit();
unit.controlWeapons(shooting, shooting);
unit.aim(pointerX, pointerY);
unit.mineTile = mining;
long elapsed = Time.timeSinceMillis(con.lastReceivedClientTime);
float maxSpeed = unit.realSpeed();
if(unit.isGrounded()){
maxSpeed *= unit.floorSpeedMultiplier();
}
long elapsed = Math.min(Time.timeSinceMillis(con.lastReceivedClientTime), 1500);
float maxSpeed = unit.speed();
float maxMove = elapsed / 1000f * 60f * maxSpeed * 1.2f;
@@ -661,7 +740,6 @@ public class NetServer implements ApplicationListener{
vector.limit(maxMove);
float prevx = unit.x, prevy = unit.y;
//unit.set(con.lastPosition);
if(!unit.isFlying()){
unit.move(vector.x, vector.y);
}else{
@@ -704,52 +782,69 @@ public class NetServer implements ApplicationListener{
}
@Remote(targets = Loc.client, called = Loc.server)
public static void adminRequest(Player player, Player other, AdminAction action){
public static void adminRequest(Player player, Player other, AdminAction action, Object params){
if(!player.admin && !player.isLocal()){
warn("ACCESS DENIED: Player @ / @ attempted to perform admin action '@' on '@' without proper security access.",
player.name, player.con == null ? "null" : player.con.address, action.name(), other == null ? null : other.name);
player.plainName(), player.con == null ? "null" : player.con.address, action.name(), other == null ? null : other.plainName());
return;
}
if(other == null || ((other.admin && !player.isLocal()) && other != player)){
warn("@ attempted to perform admin action on nonexistant or admin player.", player.name);
warn("@ &fi&lk[&lb@&fi&lk]&fb attempted to perform admin action on nonexistant or admin player.", player.plainName(), player.uuid());
return;
}
if(action == AdminAction.wave){
//no verification is done, so admins can hypothetically spam waves
//not a real issue, because server owners may want to do just that
logic.skipWave();
}else if(action == AdminAction.ban){
netServer.admins.banPlayerIP(other.con.address);
netServer.admins.banPlayerID(other.con.uuid);
other.kick(KickReason.banned);
info("&lc@ has banned @.", player.name, other.name);
}else if(action == AdminAction.kick){
other.kick(KickReason.kick);
info("&lc@ has kicked @.", player.name, other.name);
}else if(action == AdminAction.trace){
TraceInfo info = new TraceInfo(other.con.address, other.uuid(), other.con.modclient, other.con.mobile);
if(player.con != null){
Call.traceInfo(player.con, other, info);
}else{
NetClient.traceInfo(other, info);
Events.fire(new EventType.AdminRequestEvent(player, other, action));
switch(action){
case wave -> {
//no verification is done, so admins can hypothetically spam waves
//not a real issue, because server owners may want to do just that
logic.skipWave();
info("&lc@ &fi&lk[&lb@&fi&lk]&fb has skipped the wave.", player.plainName(), player.uuid());
}
case ban -> {
netServer.admins.banPlayerID(other.con.uuid);
netServer.admins.banPlayerIP(other.con.address);
other.kick(KickReason.banned);
info("&lc@ &fi&lk[&lb@&fi&lk]&fb has banned @ &fi&lk[&lb@&fi&lk]&fb.", player.plainName(), player.uuid(), other.plainName(), other.uuid());
}
case kick -> {
other.kick(KickReason.kick);
info("&lc@ &fi&lk[&lb@&fi&lk]&fb has kicked @ &fi&lk[&lb@&fi&lk]&fb.", player.plainName(), player.uuid(), other.plainName(), other.uuid());
}
case trace -> {
PlayerInfo stats = netServer.admins.getInfo(other.uuid());
TraceInfo info = new TraceInfo(other.con.address, other.uuid(), other.locale, other.con.modclient, other.con.mobile, stats.timesJoined, stats.timesKicked, stats.ips.toArray(String.class), stats.names.toArray(String.class));
if(player.con != null){
Call.traceInfo(player.con, other, info);
}else{
NetClient.traceInfo(other, info);
}
}
case switchTeam -> {
if(params instanceof Team team){
other.team(team);
}
}
info("&lc@ has requested trace info of @.", player.name, other.name);
}
}
@Remote(targets = Loc.client)
@Remote(targets = Loc.client, priority = PacketPriority.high)
public static void connectConfirm(Player player){
if(player.con.kicked) return;
player.add();
Events.fire(new PlayerConnectionConfirmed(player));
if(player.con == null || player.con.hasConnected) return;
player.con.hasConnected = true;
if(Config.showConnectMessages.bool()){
Call.sendMessage("[accent]" + player.name + "[accent] has connected.");
String message = Strings.format("&lb@&fi&lk has connected. &fi&lk[&lb@&fi&lk]", player.name, player.uuid());
String message = Strings.format("&lb@&fi&lk has connected. &fi&lk[&lb@&fi&lk]", player.plainName(), player.uuid());
info(message);
}
@@ -786,21 +881,38 @@ public class NetServer implements ApplicationListener{
}
if(state.isGame() && net.server()){
if(state.rules.pvp){
state.serverPaused = isWaitingForPlayers();
if(state.rules.pvp && state.rules.pvpAutoPause){
boolean waiting = isWaitingForPlayers(), paused = state.isPaused();
if(waiting != paused){
if(waiting){
//is now waiting, enable pausing, flag it correctly
pvpAutoPaused = true;
state.set(State.paused);
}else if(pvpAutoPaused){
//no longer waiting, stop pausing
state.set(State.playing);
pvpAutoPaused = false;
}
}
}
sync();
}
}
//TODO I don't like where this is, move somewhere else?
/** Queues a building health update. This will be sent in a Call.buildHealthUpdate packet later. */
public void buildHealthUpdate(Building build){
buildHealthChanged.add(build.pos());
}
/** Should only be used on the headless backend. */
public void openServer(){
try{
net.host(Config.port.num());
info("Opened a server on port @.", Config.port.num());
}catch(BindException e){
err("Unable to host: Port already in use! Make sure no other servers are running on the same port in your network.");
err("Unable to host: Port " + Config.port.num() + " already in use! Make sure no other servers are running on the same port in your network.");
state.set(State.menu);
}catch(IOException e){
err(e);
@@ -821,15 +933,15 @@ public class NetServer implements ApplicationListener{
short sent = 0;
for(Building entity : Groups.build){
if(!entity.block.sync) continue;
sent ++;
sent++;
dataStream.writeInt(entity.pos());
entity.writeAll(Writes.get(dataStream));
dataStream.writeShort(entity.block.id);
entity.writeSync(Writes.get(dataStream));
if(syncStream.size() > maxSnapshotSize){
dataStream.close();
byte[] stateBytes = syncStream.toByteArray();
Call.blockSnapshot(sent, (short)stateBytes.length, net.compressSnapshot(stateBytes));
Call.blockSnapshot(sent, syncStream.toByteArray());
sent = 0;
syncStream.reset();
}
@@ -837,46 +949,54 @@ public class NetServer implements ApplicationListener{
if(sent > 0){
dataStream.close();
byte[] stateBytes = syncStream.toByteArray();
Call.blockSnapshot(sent, (short)stateBytes.length, net.compressSnapshot(stateBytes));
Call.blockSnapshot(sent, syncStream.toByteArray());
}
}
public void writeEntitySnapshot(Player player) throws IOException{
byte tps = (byte)Math.min(Core.graphics.getFramesPerSecond(), 255);
syncStream.reset();
Seq<CoreBuild> cores = state.teams.cores(player.team());
int activeTeams = (byte)state.teams.present.count(t -> t.cores.size > 0);
dataStream.writeByte(cores.size);
dataStream.writeByte(activeTeams);
dataWrites.output = dataStream;
for(CoreBuild entity : cores){
dataStream.writeInt(entity.tile.pos());
entity.items.write(Writes.get(dataStream));
//block data isn't important, just send the items for each team, they're synced across cores
for(TeamData data : state.teams.present){
if(data.cores.size > 0){
dataStream.writeByte(data.team.id);
data.cores.first().items.write(dataWrites);
}
}
dataStream.close();
byte[] stateBytes = syncStream.toByteArray();
//write basic state data.
Call.stateSnapshot(player.con, state.wavetime, state.wave, state.enemies, state.serverPaused, state.gameOver, universe.seconds(), (short)stateBytes.length, net.compressSnapshot(stateBytes));
viewport.setSize(player.con.viewWidth, player.con.viewHeight).setCenter(player.con.viewX, player.con.viewY);
Call.stateSnapshot(player.con, state.wavetime, state.wave, state.enemies, state.isPaused(), state.gameOver,
universe.seconds(), tps, GlobalVars.rand.seed0, GlobalVars.rand.seed1, syncStream.toByteArray());
syncStream.reset();
hiddenIds.clear();
int sent = 0;
for(Syncc entity : Groups.sync){
//TODO write to special list
if(entity.isSyncHidden(player)){
hiddenIds.add(entity.id());
continue;
}
//write all entities now
dataStream.writeInt(entity.id()); //write id
dataStream.writeByte(entity.classId()); //write type ID
dataStream.writeByte(entity.classId() & 0xFF); //write type ID
entity.writeSync(Writes.get(dataStream)); //write entity
sent++;
if(syncStream.size() > maxSnapshotSize){
dataStream.close();
byte[] syncBytes = syncStream.toByteArray();
Call.entitySnapshot(player.con, (short)sent, (short)syncBytes.length, net.compressSnapshot(syncBytes));
Call.entitySnapshot(player.con, (short)sent, syncStream.toByteArray());
sent = 0;
syncStream.reset();
}
@@ -885,14 +1005,18 @@ public class NetServer implements ApplicationListener{
if(sent > 0){
dataStream.close();
byte[] syncBytes = syncStream.toByteArray();
Call.entitySnapshot(player.con, (short)sent, (short)syncBytes.length, net.compressSnapshot(syncBytes));
Call.entitySnapshot(player.con, (short)sent, syncStream.toByteArray());
}
if(hiddenIds.size > 0){
Call.hiddenSnapshot(player.con, hiddenIds);
}
player.con.snapshotsSent++;
}
String fixName(String name){
name = name.trim();
public String fixName(String name){
name = name.trim().replace("\n", "").replace("\t", "");
if(name.equals("[") || name.equals("]")){
return "";
}
@@ -915,20 +1039,20 @@ public class NetServer implements ApplicationListener{
return result.toString();
}
String checkColor(String str){
public String checkColor(String str){
for(int i = 1; i < str.length(); i++){
if(str.charAt(i) == ']'){
String color = str.substring(1, i);
if(Colors.get(color.toUpperCase()) != null || Colors.get(color.toLowerCase()) != null){
Color result = (Colors.get(color.toLowerCase()) == null ? Colors.get(color.toUpperCase()) : Colors.get(color.toLowerCase()));
if(result.a <= 0.8f){
if(result.a < 1f){
return str.substring(i + 1);
}
}else{
try{
Color result = Color.valueOf(color);
if(result.a <= 0.8f){
if(result.a < 1f){
return str.substring(i + 1);
}
}catch(Exception e){
@@ -942,20 +1066,23 @@ public class NetServer implements ApplicationListener{
void sync(){
try{
int interval = Config.snapshotInterval.num();
Groups.player.each(p -> !p.isLocal(), player -> {
if(player.con == null || !player.con.isConnected()){
onDisconnect(player, "disappeared");
return;
}
NetConnection connection = player.con;
var connection = player.con;
if(!player.timer(0, serverSyncTime) || !connection.hasConnected) return;
if(Time.timeSinceMillis(connection.syncTime) < interval || !connection.hasConnected) return;
connection.syncTime = Time.millis();
try{
writeEntitySnapshot(player);
}catch(IOException e){
e.printStackTrace();
Log.err(e);
}
});
@@ -963,12 +1090,91 @@ public class NetServer implements ApplicationListener{
writeBlockSnapshots();
}
if(Groups.player.size() > 0 && buildHealthChanged.size > 0 && timer.get(timerHealthSync, healthSyncTime)){
healthSeq.clear();
var iter = buildHealthChanged.iterator();
while(iter.hasNext){
int next = iter.next();
var build = world.build(next);
//pack pos + health into update list
if(build != null){
healthSeq.add(next, Float.floatToRawIntBits(build.health));
}
//if size exceeds snapshot limit, send it out and begin building it up again
if(healthSeq.size * 4 >= maxSnapshotSize){
Call.buildHealthUpdate(healthSeq);
healthSeq.clear();
}
}
//send any residual health updates
if(healthSeq.size > 0){
Call.buildHealthUpdate(healthSeq);
}
buildHealthChanged.clear();
}
}catch(IOException e){
Log.err(e);
}
}
public class VoteSession{
Player target;
ObjectIntMap<String> voted = new ObjectIntMap<>();
Timer.Task task;
int votes;
public VoteSession(Player target){
this.target = target;
this.task = Timer.schedule(() -> {
if(!checkPass()){
Call.sendMessage(Strings.format("[lightgray]Vote failed. Not enough votes to kick[orange] @[lightgray].", target.name));
currentlyKicking = null;
task.cancel();
}
}, voteDuration);
}
void vote(Player player, int d){
int lastVote = voted.get(player.uuid(), 0) | voted.get(admins.getInfo(player.uuid()).lastIP, 0);
votes -= lastVote;
votes += d;
voted.put(player.uuid(), d);
voted.put(admins.getInfo(player.uuid()).lastIP, d);
Call.sendMessage(Strings.format("[lightgray]@[lightgray] has voted on kicking[orange] @[lightgray].[accent] (@/@)\n[lightgray]Type[orange] /vote <y/n>[] to agree.",
player.name, target.name, votes, votesRequired()));
checkPass();
}
boolean checkPass(){
if(votes >= votesRequired()){
Call.sendMessage(Strings.format("[orange]Vote passed.[scarlet] @[orange] will be banned from the server for @ minutes.", target.name, (kickDuration / 60)));
Groups.player.each(p -> p.uuid().equals(target.uuid()), p -> p.kick(KickReason.vote, kickDuration * 1000));
currentlyKicking = null;
task.cancel();
return true;
}
return false;
}
}
public interface TeamAssigner{
Team assign(Player player, Iterable<Player> players);
}
public interface ChatFormatter{
/** @return text to be placed before player name */
String format(@Nullable Player player, String message);
}
public interface InvalidCommandHandler{
String handle(Player player, CommandResponse response);
}
}
+164 -9
View File
@@ -1,6 +1,7 @@
package mindustry.core;
import arc.*;
import arc.filedialogs.*;
import arc.files.*;
import arc.func.*;
import arc.math.*;
@@ -14,16 +15,36 @@ import mindustry.type.*;
import mindustry.ui.dialogs.*;
import rhino.*;
import java.io.*;
import java.net.*;
import static mindustry.Vars.*;
public interface Platform{
/** Dynamically loads a jar file. */
default Class<?> loadJar(Fi jar, String mainClass) throws Exception{
URLClassLoader classLoader = new URLClassLoader(new URL[]{jar.file().toURI().toURL()}, getClass().getClassLoader());
return Class.forName(mainClass, true, classLoader);
/** Dynamically creates a class loader for a jar file. This loader must be child-first. */
default ClassLoader loadJar(Fi jar, ClassLoader parent) throws Exception{
return new URLClassLoader(new URL[]{jar.file().toURI().toURL()}, parent){
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
//check for loaded state
Class<?> loadedClass = findLoadedClass(name);
if(loadedClass == null){
try{
//try to load own class first
loadedClass = findClass(name);
}catch(ClassNotFoundException e){
//use parent if not found
return parent.loadClass(name);
}
}
if(resolve){
resolveClass(loadedClass);
}
return loadedClass;
}
};
}
/** Steam: Update lobby visibility.*/
@@ -60,9 +81,10 @@ public interface Platform{
}
default Context getScriptContext(){
Context c = Context.enter();
c.setOptimizationLevel(9);
return c;
Context context = Context.getCurrentContext();
if(context == null) context = Context.enter();
context.setOptimizationLevel(9);
return context;
}
/** Update discord RPC. */
@@ -101,7 +123,7 @@ public interface Platform{
}else{
ui.loadAnd(() -> {
try{
Fi result = Core.files.local(name+ "." + extension);
Fi result = Core.files.local(name + "." + extension);
writer.write(result);
platform.shareFile(result);
}catch(Throwable e){
@@ -120,6 +142,69 @@ public interface Platform{
* @param title The title of the native dialog
*/
default void showFileChooser(boolean open, String title, String extension, Cons<Fi> cons){
if(OS.isWindows || OS.isMac){
showNativeFileChooser(open, title, cons, extension);
}else if(OS.isLinux && !OS.isAndroid){
showZenity(open, title, new String[]{extension}, cons, () -> defaultFileDialog(open, title, extension, cons));
}else{
defaultFileDialog(open, title, extension, cons);
}
}
/** attempt to use the native file picker with zenity, or runs the fallback Runnable if the operation fails */
static void showZenity(boolean open, String title, String[] extensions, Cons<Fi> cons, Runnable fallback){
Threads.daemon(() -> {
try{
String formatted = (title.startsWith("@") ? Core.bundle.get(title.substring(1)) : title).replaceAll("\"", "'");
String last = FileChooser.getLastDirectory().absolutePath();
if(!last.endsWith("/")) last += "/";
//zenity doesn't support filtering by extension
Seq<String> args = Seq.with("zenity",
"--file-selection",
"--title=" + formatted,
"--filename=" + last,
"--confirm-overwrite",
"--file-filter=" + Seq.with(extensions).toString(" ", s -> "*." + s),
"--file-filter=All files | *" //allow anything if the user wants
);
if(!open){
args.add("--save");
}
String result = OS.exec(args.toArray(String.class));
//first line.
if(result.length() > 1 && result.contains("\n")){
result = result.split("\n")[0];
}
//cancelled selection, ignore result
if(result.isEmpty() || result.equals("\n")) return;
if(result.endsWith("\n")) result = result.substring(0, result.length() - 1);
if(result.contains("\n")) throw new IOException("invalid input: \"" + result + "\"");
Fi file = Core.files.absolute(result);
Core.app.post(() -> {
FileChooser.setLastDirectory(file.isDirectory() ? file : file.parent());
if(!open){
cons.get(file.parent().child(file.nameWithoutExtension() + "." + extensions[0]));
}else{
cons.get(file);
}
});
}catch(Exception e){
Log.err(e);
Log.warn("zenity not found, using non-native file dialog. Consider installing `zenity` for native file dialogs.");
Core.app.post(fallback);
}
});
}
static void defaultFileDialog(boolean open, String title, String extension, Cons<Fi> cons){
new FileChooser(title, file -> file.extEquals(extension), open, file -> {
if(!open){
cons.get(file.parent().child(file.nameWithoutExtension() + "." + extension));
@@ -141,11 +226,81 @@ public interface Platform{
default void showMultiFileChooser(Cons<Fi> cons, String... extensions){
if(mobile){
showFileChooser(true, extensions[0], cons);
}else if(OS.isWindows || OS.isMac){
showNativeFileChooser(true, "@open", cons, extensions);
}else if(OS.isLinux && !OS.isAndroid){
showZenity(true, "@open", extensions, cons, () -> defaultMultiFileChooser(cons, extensions));
}else{
new FileChooser("@open", file -> Structs.contains(extensions, file.extension().toLowerCase()), true, cons).show();
defaultMultiFileChooser(cons, extensions);
}
}
static void defaultMultiFileChooser(Cons<Fi> cons, String... extensions){
new FileChooser("@open", file -> Structs.contains(extensions, file.extension().toLowerCase()), true, cons).show();
}
default void showNativeFileChooser(boolean open, String title, Cons<Fi> cons, String... shownExtensions){
String formatted = (title.startsWith("@") ? Core.bundle.get(title.substring(1)) : title).replaceAll("\"", "'");
//this should never happen unless someone is being dumb with the parameters
String[] ext = shownExtensions == null || shownExtensions.length == 0 ? new String[]{""} : shownExtensions;
//native file dialog
Threads.daemon(() -> {
try{
FileDialogs.loadNatives();
String result;
String[] patterns = new String[ext.length];
for(int i = 0; i < ext.length; i++){
patterns[i] = "*." + ext[i];
}
//on MacOS, .msav is not properly recognized until I put garbage into the array?
if(patterns.length == 1 && OS.isMac && open){
patterns = new String[]{"", "*." + ext[0]};
}
if(open){
result = FileDialogs.openFileDialog(formatted, FileChooser.getLastDirectory().absolutePath(), patterns, "." + ext[0] + " files", false);
}else{
result = FileDialogs.saveFileDialog(formatted, FileChooser.getLastDirectory().child("file." + ext[0]).absolutePath(), patterns, "." + ext[0] + " files");
}
if(result == null) return;
if(result.length() > 1 && result.contains("\n")){
result = result.split("\n")[0];
}
//cancelled selection, ignore result
if(result.isEmpty() || result.equals("\n")) return;
if(result.endsWith("\n")) result = result.substring(0, result.length() - 1);
if(result.contains("\n")) throw new IOException("invalid input: \"" + result + "\"");
Fi file = Core.files.absolute(result);
Core.app.post(() -> {
FileChooser.setLastDirectory(file.isDirectory() ? file : file.parent());
if(!open){
cons.get(file.parent().child(file.nameWithoutExtension() + "." + ext[0]));
}else{
cons.get(file);
}
});
}catch(Throwable error){
Log.err("Failure to execute native file chooser", error);
Core.app.post(() -> {
if(ext.length > 1){
defaultMultiFileChooser(cons, ext);
}else{
defaultFileDialog(open, title, ext[0], cons);
}
});
}
});
}
/** Hide the app. Android only. */
default void hide(){
}
+331 -117
View File
@@ -1,29 +1,36 @@
package mindustry.core;
import arc.*;
import arc.assets.loaders.TextureLoader.*;
import arc.audio.*;
import arc.files.*;
import arc.fx.*;
import arc.graphics.*;
import arc.graphics.Texture.*;
import arc.graphics.g2d.*;
import arc.graphics.gl.*;
import arc.math.*;
import arc.math.geom.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import mindustry.content.*;
import mindustry.*;
import mindustry.game.EventType.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.graphics.g3d.*;
import mindustry.ui.*;
import mindustry.maps.*;
import mindustry.type.*;
import mindustry.world.blocks.*;
import static arc.Core.*;
import static mindustry.Vars.*;
public class Renderer implements ApplicationListener{
/** These are global variables, for headless access. Cached. */
public static float laserOpacity = 0.5f, bridgeOpacity = 0.75f;
public static float laserOpacity = 0.5f, unitLaserOpacity = 1f, bridgeOpacity = 0.75f;
public final BlockRenderer blocks = new BlockRenderer();
public final FogRenderer fog = new FogRenderer();
public final MinimapRenderer minimap = new MinimapRenderer();
public final OverlayRenderer overlays = new OverlayRenderer();
public final LightRenderer lights = new LightRenderer();
@@ -31,28 +38,59 @@ public class Renderer implements ApplicationListener{
public PlanetRenderer planets;
public @Nullable Bloom bloom;
public @Nullable FrameBuffer backgroundBuffer;
public FrameBuffer effectBuffer = new FrameBuffer();
public boolean animateShields, drawWeather = true;
public boolean animateShields, drawWeather = true, drawStatus, enableEffects, drawDisplays = true, drawLight = true, pixelate = false;
public float weatherAlpha;
/** minZoom = zooming out, maxZoom = zooming in */
public float minZoom = 1.5f, maxZoom = 6f;
public Seq<EnvRenderer> envRenderers = new Seq<>();
public ObjectMap<String, Runnable> customBackgrounds = new ObjectMap<>();
public TextureRegion[] bubbles = new TextureRegion[16], splashes = new TextureRegion[12];
public TextureRegion[][] fluidFrames;
//TODO unused
private FxProcessor fx = new FxProcessor();
//currently landing core, null if there are no cores or it has finished landing.
private @Nullable LaunchAnimator launchAnimator;
private Color clearColor = new Color(0f, 0f, 0f, 1f);
private float targetscale = Scl.scl(4);
private float camerascale = targetscale;
private float landscale = 0f, landTime, weatherAlpha;
private float minZoomScl = Scl.scl(0.01f);
private float shakeIntensity, shaketime;
private float
//target camera scale that is lerp-ed to
targetscale = Scl.scl(4),
//current actual camera scale
camerascale = targetscale,
//starts at coreLandDuration, ends at 0. if positive, core is landing.
landTime,
//intensity for screen shake
shakeIntensity,
//reduction rate of screen shake
shakeReduction,
//current duration of screen shake
shakeTime;
//for landTime > 0: if true, core is currently *launching*, otherwise landing.
private boolean launching;
private Vec2 camShakeOffset = new Vec2();
public Renderer(){
camera = new Camera();
Shaders.init();
Events.on(ResetEvent.class, e -> {
shakeTime = shakeIntensity = shakeReduction = 0f;
camShakeOffset.setZero();
});
}
public void shake(float intensity, float duration){
shakeIntensity = Math.max(shakeIntensity, intensity);
shaketime = Math.max(shaketime, duration);
shakeIntensity = Math.max(shakeIntensity, Mathf.clamp(intensity, 0, 100));
shakeTime = Math.max(shakeTime, duration);
shakeReduction = shakeIntensity / shakeTime;
}
public void addEnvRenderer(int mask, Runnable render){
envRenderers.add(new EnvRenderer(mask, render));
}
public void addCustomBackground(String name, Runnable render){
customBackgrounds.put(name, render);
}
@Override
@@ -62,29 +100,92 @@ public class Renderer implements ApplicationListener{
if(settings.getBool("bloom", !ios)){
setupBloom();
}
EnvRenderers.init();
for(int i = 0; i < bubbles.length; i++) bubbles[i] = atlas.find("bubble-" + i);
for(int i = 0; i < splashes.length; i++) splashes[i] = atlas.find("splash-" + i);
loadFluidFrames();
Events.on(ClientLoadEvent.class, e -> {
loadFluidFrames();
});
assets.load("sprites/clouds.png", Texture.class).loaded = t -> {
t.setWrap(TextureWrap.repeat);
t.setFilter(TextureFilter.linear);
};
Events.on(WorldLoadEvent.class, e -> {
//reset background buffer on every world load, so it can be re-cached first render
if(backgroundBuffer != null){
backgroundBuffer.dispose();
backgroundBuffer = null;
}
});
}
public void loadFluidFrames(){
fluidFrames = new TextureRegion[2][Liquid.animationFrames];
String[] fluidTypes = {"liquid", "gas"};
for(int i = 0; i < fluidTypes.length; i++){
for(int j = 0; j < Liquid.animationFrames; j++){
fluidFrames[i][j] = atlas.find("fluid-" + fluidTypes[i] + "-" + j);
}
}
}
public TextureRegion[][] getFluidFrames(){
if(fluidFrames == null || fluidFrames[0][0].texture.isDisposed()){
loadFluidFrames();
}
return fluidFrames;
}
@Override
public void update(){
Color.white.set(1f, 1f, 1f, 1f);
Gl.clear(Gl.stencilBufferBit);
float dest = Mathf.round(targetscale, 0.5f);
float baseTarget = targetscale;
if(control.input.logicCutscene){
baseTarget = Mathf.lerp(minZoom, maxZoom, control.input.logicCutsceneZoom);
}
float dest = Mathf.clamp(Mathf.round(baseTarget, 0.5f), minScale(), maxScale());
camerascale = Mathf.lerpDelta(camerascale, dest, 0.1f);
if(Mathf.equal(camerascale, dest, 0.001f)) camerascale = dest;
unitLaserOpacity = settings.getInt("unitlaseropacity") / 100f;
laserOpacity = settings.getInt("lasersopacity") / 100f;
bridgeOpacity = settings.getInt("bridgeopacity") / 100f;
animateShields = settings.getBool("animatedshields");
drawStatus = settings.getBool("blockstatus");
enableEffects = settings.getBool("effects");
drawDisplays = !settings.getBool("hidedisplays");
drawLight = settings.getBool("drawlight", true);
pixelate = settings.getBool("pixelate");
//don't bother drawing landing animation if core is null
if(launchAnimator == null) landTime = 0f;
if(landTime > 0){
landTime -= Time.delta;
landscale = Interp.pow5In.apply(minZoomScl, Scl.scl(4f), 1f - landTime / Fx.coreLand.lifetime);
camerascale = landscale;
if(!state.isPaused()) launchAnimator.updateLaunch();
weatherAlpha = 0f;
camerascale = launchAnimator.zoomLaunch();
if(!state.isPaused()) landTime -= Time.delta;
}else{
weatherAlpha = Mathf.lerpDelta(weatherAlpha, 1f, 0.08f);
}
if(launchAnimator != null && landTime <= 0f){
launchAnimator.endLaunch();
launchAnimator = null;
}
camera.width = graphics.getWidth() / camerascale;
camera.height = graphics.getHeight() / camerascale;
@@ -92,48 +193,49 @@ public class Renderer implements ApplicationListener{
landTime = 0f;
graphics.clear(Color.black);
}else{
updateShake(0.75f);
if(pixelator.enabled()){
minimap.update();
if(shakeTime > 0){
float intensity = shakeIntensity * (settings.getInt("screenshake", 4) / 4f) * 0.75f;
camShakeOffset.setToRandomDirection().scl(Mathf.random(intensity));
camera.position.add(camShakeOffset);
shakeIntensity -= shakeReduction * Time.delta;
shakeTime -= Time.delta;
shakeIntensity = Mathf.clamp(shakeIntensity, 0f, 100f);
}else{
camShakeOffset.setZero();
shakeIntensity = 0f;
}
if(renderer.pixelate){
pixelator.drawPixelate();
}else{
draw();
}
camera.position.sub(camShakeOffset);
}
}
public boolean isLanding(){
public void updateAllDarkness(){
blocks.updateDarkness();
minimap.updateAll();
}
/** @return whether a launch/land cutscene is playing. */
public boolean isCutscene(){
return landTime > 0;
}
public float weatherAlpha(){
return weatherAlpha;
}
public float landScale(){
return landTime > 0 ? landscale : 1f;
return landTime > 0 ? camerascale : 1f;
}
@Override
public void dispose(){
minimap.dispose();
effectBuffer.dispose();
blocks.dispose();
if(planets != null){
planets.dispose();
planets = null;
}
if(bloom != null){
bloom.dispose();
bloom = null;
}
Events.fire(new DisposeEvent());
}
@Override
public void resize(int width, int height){
fx.resize(width, height);
}
@Override
public void resume(){
if(settings.getBool("bloom") && bloom != null){
@@ -168,37 +270,9 @@ public class Renderer implements ApplicationListener{
}
}
void beginFx(){
if(!fx.hasEnabledEffects()) return;
Draw.flush();
fx.clear();
fx.begin();
}
void endFx(){
if(!fx.hasEnabledEffects()) return;
Draw.flush();
fx.end();
fx.applyEffects();
fx.render(0, 0, fx.getWidth(), fx.getHeight());
}
void updateShake(float scale){
if(shaketime > 0){
float intensity = shakeIntensity * (settings.getInt("screenshake", 4) / 4f) * scale;
camera.position.add(Mathf.range(intensity), Mathf.range(intensity));
shakeIntensity -= 0.25f * Time.delta;
shaketime -= Time.delta;
shakeIntensity = Mathf.clamp(shakeIntensity, 0f, 100f);
}else{
shakeIntensity = 0f;
}
}
public void draw(){
Events.fire(Trigger.preDraw);
MapPreviewLoader.checkPreviews();
camera.update();
@@ -209,27 +283,29 @@ public class Renderer implements ApplicationListener{
graphics.clear(clearColor);
Draw.reset();
if(Core.settings.getBool("animatedwater") || animateShields){
if(settings.getBool("animatedwater") || animateShields){
effectBuffer.resize(graphics.getWidth(), graphics.getHeight());
}
Draw.proj(camera);
blocks.checkChanges();
blocks.floor.checkChanges();
blocks.processBlocks();
Draw.sort(true);
Events.fire(Trigger.draw);
MapPreviewLoader.checkPreviews();
if(pixelator.enabled()){
if(renderer.pixelate){
pixelator.register();
}
Draw.draw(Layer.background, this::drawBackground);
Draw.draw(Layer.floor, blocks.floor::drawFloor);
Draw.draw(Layer.block - 1, blocks::drawShadows);
Draw.draw(Layer.block, () -> {
Draw.draw(Layer.block - 0.09f, () -> {
blocks.floor.beginDraw();
blocks.floor.drawLayer(CacheLayer.walls);
blocks.floor.endDraw();
@@ -237,7 +313,14 @@ public class Renderer implements ApplicationListener{
Draw.drawRange(Layer.blockBuilding, () -> Draw.shader(Shaders.blockbuild, true), Draw::shader);
if(state.rules.lighting){
//render all matching environments
for(var renderer : envRenderers){
if((renderer.env & state.rules.env) == renderer.env){
renderer.renderer.run();
}
}
if(state.rules.lighting && drawLight){
Draw.draw(Layer.light, lights::draw);
}
@@ -246,14 +329,19 @@ public class Renderer implements ApplicationListener{
}
if(bloom != null){
bloom.resize(graphics.getWidth() / 4, graphics.getHeight() / 4);
Draw.draw(Layer.bullet - 0.01f, bloom::capture);
Draw.draw(Layer.effect + 0.01f, bloom::render);
bloom.resize(graphics.getWidth(), graphics.getHeight());
bloom.setBloomIntensity(settings.getInt("bloomintensity", 6) / 4f + 1f);
bloom.blurPasses = settings.getInt("bloomblur", 1);
Draw.draw(Layer.bullet - 0.02f, bloom::capture);
Draw.draw(Layer.effect + 0.02f, bloom::render);
}
control.input.drawCommanded();
Draw.draw(Layer.plans, overlays::drawBottom);
if(animateShields && Shaders.shield != null){
//TODO would be nice if there were a way to detect if any shields or build beams actually *exist* before beginning/ending buffers, otherwise you're just blitting and swapping shaders for nothing
Draw.drawRange(Layer.shields, 1f, () -> effectBuffer.begin(Color.clear), () -> {
effectBuffer.end();
effectBuffer.blit(Shaders.shield);
@@ -265,9 +353,38 @@ public class Renderer implements ApplicationListener{
});
}
Draw.draw(Layer.overlayUI, overlays::drawTop);
Draw.draw(Layer.space, this::drawLanding);
float scaleFactor = 4f / renderer.getDisplayScale();
//draw objective markers
state.rules.objectives.eachRunning(obj -> {
for(var marker : obj.markers){
if(marker.world){
marker.draw(marker.autoscale ? scaleFactor : 1);
}
}
});
for(var marker : state.markers){
if(marker.world){
marker.draw(marker.autoscale ? scaleFactor : 1);
}
}
Draw.reset();
Draw.draw(Layer.overlayUI, overlays::drawTop);
if(state.rules.fog) Draw.draw(Layer.fogOfWar, fog::drawFog);
Draw.draw(Layer.space, () -> {
if(launchAnimator == null || landTime <= 0f) return;
launchAnimator.drawLaunch();
});
if(launchAnimator != null){
Draw.z(Layer.space);
launchAnimator.drawLaunchGlobalZ();
Draw.reset();
}
Events.fire(Trigger.drawOver);
blocks.drawBlocks();
Groups.draw.draw(Drawc::draw);
@@ -279,32 +396,79 @@ public class Renderer implements ApplicationListener{
Events.fire(Trigger.postDraw);
}
private void drawBackground(){
protected void drawBackground(){
//draw background only if there is no planet background with a skybox
if(state.rules.backgroundTexture != null && (state.rules.planetBackground == null || !state.rules.planetBackground.drawSkybox)){
if(!assets.isLoaded(state.rules.backgroundTexture, Texture.class)){
var file = assets.getFileHandleResolver().resolve(state.rules.backgroundTexture);
}
//don't draw invalid/non-existent backgrounds.
if(!file.exists() || !file.extEquals("png")){
return;
}
private void drawLanding(){
if(landTime > 0 && player.closestCore() != null){
float fract = landTime / Fx.coreLand.lifetime;
Building entity = player.closestCore();
var desc = assets.load(state.rules.backgroundTexture, Texture.class, new TextureParameter(){{
wrapU = wrapV = TextureWrap.mirroredRepeat;
magFilter = minFilter = TextureFilter.linear;
}});
TextureRegion reg = entity.block.icon(Cicon.full);
float scl = Scl.scl(4f) / camerascale;
float s = reg.width * Draw.scl * scl * 4f * fract;
assets.finishLoadingAsset(desc);
}
Draw.color(Pal.lightTrail);
Draw.rect("circle-shadow", entity.getX(), entity.getY(), s, s);
Texture tex = assets.get(state.rules.backgroundTexture, Texture.class);
Tmp.tr1.set(tex);
Tmp.tr1.u = 0f;
Tmp.tr1.v = 0f;
Angles.randLenVectors(1, (1f- fract), 100, 1000f * scl * (1f-fract), (x, y, fin, fout) -> {
Lines.stroke(scl * fin);
Lines.lineAngle(entity.getX() + x, entity.getY() + y, Mathf.angle(x, y), (fin * 20 + 1f) * scl);
});
float ratio = camera.width / camera.height;
float size = state.rules.backgroundScl;
Draw.color();
Draw.mixcol(Color.white, fract);
Draw.rect(reg, entity.getX(), entity.getY(), reg.width * Draw.scl * scl, reg.height * Draw.scl * scl, fract * 135f);
Tmp.tr1.u2 = size;
Tmp.tr1.v2 = size / ratio;
Draw.reset();
float sx = 0f, sy = 0f;
if(!Mathf.zero(state.rules.backgroundSpeed)){
sx = (camera.position.x) / state.rules.backgroundSpeed;
sy = (camera.position.y) / state.rules.backgroundSpeed;
}
Tmp.tr1.scroll(sx + state.rules.backgroundOffsetX, -sy + state.rules.backgroundOffsetY);
Draw.rect(Tmp.tr1, camera.position.x, camera.position.y, camera.width, camera.height);
}
if(state.rules.planetBackground != null){
int size = Math.max(graphics.getWidth(), graphics.getHeight());
boolean resized = false;
if(backgroundBuffer == null){
resized = true;
backgroundBuffer = new FrameBuffer(size, size);
}
if(resized || backgroundBuffer.resizeCheck(size, size)){
backgroundBuffer.begin(Color.clear);
var params = state.rules.planetBackground;
//override some values
params.viewW = size;
params.viewH = size;
params.alwaysDrawAtmosphere = true;
params.drawUi = false;
planets.render(params);
backgroundBuffer.end();
}
float drawSize = Math.max(camera.width, camera.height);
Draw.rect(Draw.wrap(backgroundBuffer.getTexture()), camera.position.x, camera.position.y, drawSize, -drawSize);
}
if(state.rules.customBackgroundCallback != null && customBackgrounds.containsKey(state.rules.customBackgroundCallback)){
customBackgrounds.get(state.rules.customBackgroundCallback).run();
}
}
@@ -338,16 +502,52 @@ public class Renderer implements ApplicationListener{
clampScale();
}
public void zoomIn(float duration){
landscale = minZoomScl;
landTime = duration;
public boolean isLaunching(){
return launching;
}
public float getLandTime(){
return landTime;
}
public float getLandTimeIn(){
if(launchAnimator == null) return 0f;
float fin = landTime / launchAnimator.launchDuration();
if(!launching) fin = 1f - fin;
return fin;
}
public void showLanding(LaunchAnimator landCore){
this.launchAnimator = landCore;
launching = false;
landTime = landCore.launchDuration();
landCore.beginLaunch(false);
camerascale = landCore.zoomLaunch();
}
public void showLaunch(LaunchAnimator landCore){
control.input.config.hideConfig();
control.input.planConfig.hide();
control.input.inv.hide();
this.launchAnimator = landCore;
launching = true;
landTime = landCore.launchDuration();
Music music = landCore.launchMusic();
music.stop();
music.play();
music.setVolume(settings.getInt("musicvol") / 100f);
landCore.beginLaunch(true);
}
public void takeMapScreenshot(){
int w = world.width() * tilesize, h = world.height() * tilesize;
int memory = w * h * 4 / 1024 / 1024;
if(memory >= 65){
if(Vars.checkScreenshotMemory && memory >= (mobile ? 65 : 120)){
ui.showInfo("@screenshot.invalid");
return;
}
@@ -363,25 +563,39 @@ public class Renderer implements ApplicationListener{
camera.position.y = h / 2f + tilesize / 2f;
buffer.begin();
draw();
Draw.flush();
byte[] lines = ScreenUtils.getFrameBufferPixels(0, 0, w, h, true);
buffer.end();
disableUI = false;
camera.width = vpW;
camera.height = vpH;
camera.position.set(px, py);
buffer.begin();
byte[] lines = ScreenUtils.getFrameBufferPixels(0, 0, w, h, true);
for(int i = 0; i < lines.length; i += 4){
lines[i + 3] = (byte)255;
}
buffer.end();
Pixmap fullPixmap = new Pixmap(w, h, Pixmap.Format.rgba8888);
Buffers.copy(lines, 0, fullPixmap.getPixels(), lines.length);
Fi file = screenshotDirectory.child("screenshot-" + Time.millis() + ".png");
PixmapIO.writePNG(file, fullPixmap);
fullPixmap.dispose();
ui.showInfoFade(Core.bundle.format("screenshot", file.toString()));
drawWeather = true;
buffer.dispose();
Threads.thread(() -> {
for(int i = 0; i < lines.length; i += 4){
lines[i + 3] = (byte)255;
}
Pixmap fullPixmap = new Pixmap(w, h);
Buffers.copy(lines, 0, fullPixmap.pixels, lines.length);
Fi file = screenshotDirectory.child("screenshot-" + Time.millis() + ".png");
PixmapIO.writePng(file, fullPixmap);
fullPixmap.dispose();
app.post(() -> ui.showInfoFade(bundle.format("screenshot", file.toString())));
});
}
public static class EnvRenderer{
/** Environment bitmask; must match env exactly when and-ed. */
public final int env;
/** Rendering callback. */
public final Runnable renderer;
public EnvRenderer(int env, Runnable renderer){
this.env = env;
this.renderer = renderer;
}
}
}
+235 -60
View File
@@ -33,12 +33,14 @@ import static arc.scene.actions.Actions.*;
import static mindustry.Vars.*;
public class UI implements ApplicationListener, Loadable{
public static String billions, millions, thousands;
public static PixmapPacker packer;
public MenuFragment menufrag;
public HudFragment hudfrag;
public ChatFragment chatfrag;
public ScriptConsoleFragment scriptfrag;
public ConsoleFragment consolefrag;
public MinimapFragment minimapfrag;
public PlayerListFragment listfrag;
public LoadingFragment loadfrag;
@@ -49,14 +51,14 @@ public class UI implements ApplicationListener, Loadable{
public AboutDialog about;
public GameOverDialog restart;
public CustomGameDialog custom;
public MapsDialog maps;
public EditorMapsDialog maps;
public LoadDialog load;
public DiscordDialog discord;
public JoinDialog join;
public HostDialog host;
public PausedDialog paused;
public SettingsMenuDialog settings;
public ControlsDialog controls;
public KeybindDialog controls;
public MapEditorDialog editor;
public LanguageDialog language;
public BansDialog bans;
@@ -69,14 +71,29 @@ public class UI implements ApplicationListener, Loadable{
public SchematicsDialog schematics;
public ModsDialog mods;
public ColorPicker picker;
public EffectsDialog effects;
public LogicDialog logic;
public FullTextDialog fullText;
public CampaignCompleteDialog campaignComplete;
public Cursor drillCursor, unloadCursor;
public IntMap<Dialog> followUpMenus;
public Cursor drillCursor, unloadCursor, targetCursor, repairCursor;
private @Nullable Element lastAnnouncement;
public UI(){
Fonts.loadFonts();
}
public static void loadColors(){
Colors.put("accent", Pal.accent);
Colors.put("unlaunched", Color.valueOf("8982ed"));
Colors.put("highlight", Pal.accent.cpy().lerp(Color.white, 0.3f));
Colors.put("stat", Pal.stat);
Colors.put("negstat", Pal.negativeStat);
}
@Override
public void loadAsync(){
@@ -108,7 +125,10 @@ public class UI implements ApplicationListener, Loadable{
Dialog.setHideAction(() -> sequence(fadeOut(0.1f)));
Tooltips.getInstance().animations = false;
Tooltips.getInstance().textProvider = text -> new Tooltip(t -> t.background(Styles.black5).margin(4f).add(text));
Tooltips.getInstance().textProvider = text -> new Tooltip(t -> t.background(Styles.black6).margin(4f).add(text));
if(mobile){
Tooltips.getInstance().offsetY += Scl.scl(60f);
}
Core.settings.setErrorHandler(e -> {
Log.err(e);
@@ -117,18 +137,15 @@ public class UI implements ApplicationListener, Loadable{
ClickListener.clicked = () -> Sounds.press.play();
Colors.put("accent", Pal.accent);
Colors.put("unlaunched", Color.valueOf("8982ed"));
Colors.put("highlight", Pal.accent.cpy().lerp(Color.white, 0.3f));
Colors.put("stat", Pal.stat);
drillCursor = Core.graphics.newCursor("drill", Fonts.cursorScale());
unloadCursor = Core.graphics.newCursor("unload", Fonts.cursorScale());
targetCursor = Core.graphics.newCursor("target", Fonts.cursorScale());
repairCursor = Core.graphics.newCursor("repair", Fonts.cursorScale());
}
@Override
public Seq<AssetDescriptor> getDependencies(){
return Seq.with(new AssetDescriptor<>(Control.class), new AssetDescriptor<>("outline", Font.class), new AssetDescriptor<>("default", Font.class), new AssetDescriptor<>("chat", Font.class));
return Seq.with(new AssetDescriptor<>(Control.class), new AssetDescriptor<>("outline", Font.class), new AssetDescriptor<>("default", Font.class));
}
@Override
@@ -140,8 +157,8 @@ public class UI implements ApplicationListener, Loadable{
Core.scene.act();
Core.scene.draw();
if(Core.input.keyTap(KeyCode.mouseLeft) && Core.scene.getKeyboardFocus() instanceof TextField){
Element e = Core.scene.hit(Core.input.mouseX(), Core.input.mouseY(), true);
if(Core.input.keyTap(KeyCode.mouseLeft) && Core.scene.hasField()){
Element e = Core.scene.getHoverElement();
if(!(e instanceof TextField)){
Core.scene.setKeyboardFocus(null);
}
@@ -152,6 +169,10 @@ public class UI implements ApplicationListener, Loadable{
@Override
public void init(){
billions = Core.bundle.get("unit.billions");
millions = Core.bundle.get("unit.millions");
thousands = Core.bundle.get("unit.thousands");
menuGroup = new WidgetGroup();
hudGroup = new WidgetGroup();
@@ -162,11 +183,12 @@ public class UI implements ApplicationListener, Loadable{
minimapfrag = new MinimapFragment();
listfrag = new PlayerListFragment();
loadfrag = new LoadingFragment();
scriptfrag = new ScriptConsoleFragment();
consolefrag = new ConsoleFragment();
picker = new ColorPicker();
effects = new EffectsDialog();
editor = new MapEditorDialog();
controls = new ControlsDialog();
controls = new KeybindDialog();
restart = new GameOverDialog();
join = new JoinDialog();
discord = new DiscordDialog();
@@ -181,13 +203,16 @@ public class UI implements ApplicationListener, Loadable{
bans = new BansDialog();
admins = new AdminsDialog();
traces = new TraceDialog();
maps = new MapsDialog();
maps = new EditorMapsDialog();
content = new ContentInfoDialog();
planet = new PlanetDialog();
research = new ResearchDialog();
mods = new ModsDialog();
schematics = new SchematicsDialog();
logic = new LogicDialog();
fullText = new FullTextDialog();
campaignComplete = new CampaignCompleteDialog();
followUpMenus = new IntMap<>();
Group group = Core.scene.root;
@@ -203,10 +228,10 @@ public class UI implements ApplicationListener, Loadable{
hudfrag.build(hudGroup);
menufrag.build(menuGroup);
chatfrag.container().build(hudGroup);
chatfrag.build(hudGroup);
minimapfrag.build(hudGroup);
listfrag.build(hudGroup);
scriptfrag.container().build(hudGroup);
consolefrag.build(hudGroup);
loadfrag.build(group);
new FadeInFragment().build(group);
}
@@ -214,18 +239,17 @@ public class UI implements ApplicationListener, Loadable{
@Override
public void resize(int width, int height){
if(Core.scene == null) return;
int[] insets = Core.graphics.getSafeInsets();
Core.scene.marginLeft = insets[0];
Core.scene.marginRight = insets[1];
Core.scene.marginTop = insets[2];
Core.scene.marginBottom = insets[3];
Core.scene.resize(width, height);
Events.fire(new ResizeEvent());
}
@Override
public void dispose(){
if(packer != null){
packer.dispose();
packer = null;
}
}
public TextureRegionDrawable getIcon(String name){
if(Icon.icons.containsKey(name)) return Icon.icons.get(name);
return Core.atlas.getDrawable("error");
@@ -248,37 +272,53 @@ public class UI implements ApplicationListener, Loadable{
});
}
public void showTextInput(String titleText, String dtext, int textLength, String def, boolean inumeric, Cons<String> confirmed){
public void showTextInput(String titleText, String text, int textLength, String def, boolean numbers, Cons<String> confirmed, Runnable closed) {
showTextInput(titleText, text, textLength, def, numbers, false, confirmed, closed);
}
public void showTextInput(String titleText, String text, int textLength, String def, boolean numbers, boolean allowEmpty, Cons<String> confirmed, Runnable closed){
if(mobile){
var description = (text.startsWith("@") ? Core.bundle.get(text.substring(1)) : text);
var empty = allowEmpty;
Core.input.getTextInput(new TextInput(){{
this.title = (titleText.startsWith("@") ? Core.bundle.get(titleText.substring(1)) : titleText);
this.text = def;
this.numeric = inumeric;
this.numeric = numbers;
this.maxLength = textLength;
this.accepted = confirmed;
this.canceled = closed;
this.allowEmpty = empty;
this.message = description;
}});
}else{
new Dialog(titleText){{
cont.margin(30).add(dtext).padRight(6f);
TextFieldFilter filter = inumeric ? TextFieldFilter.digitsOnly : (f, c) -> true;
cont.margin(30).add(text).padRight(6f);
TextFieldFilter filter = numbers ? TextFieldFilter.digitsOnly : (f, c) -> true;
TextField field = cont.field(def, t -> {}).size(330f, 50f).get();
field.setFilter((f, c) -> field.getText().length() < textLength && filter.acceptChar(f, c));
field.setMaxLength(textLength);
field.setFilter(filter);
buttons.defaults().size(120, 54).pad(4);
buttons.button("@cancel", this::hide);
buttons.button("@cancel", () -> {
closed.run();
hide();
});
buttons.button("@ok", () -> {
confirmed.get(field.getText());
hide();
}).disabled(b -> field.getText().isEmpty());
}).disabled(b -> !allowEmpty && field.getText().isEmpty());
keyDown(KeyCode.enter, () -> {
String text = field.getText();
if(!text.isEmpty()){
if(allowEmpty || !text.isEmpty()){
confirmed.get(text);
hide();
}
});
keyDown(KeyCode.escape, this::hide);
keyDown(KeyCode.back, this::hide);
closeOnBack(closed);
show();
Core.scene.setKeyboardFocus(field);
field.setCursorPosition(def.length());
}};
@@ -289,30 +329,61 @@ public class UI implements ApplicationListener, Loadable{
showTextInput(title, text, 32, def, confirmed);
}
public void showTextInput(String titleText, String text, int textLength, String def, Cons<String> confirmed){
showTextInput(titleText, text, textLength, def, false, confirmed);
public void showTextInput(String title, String text, int textLength, String def, Cons<String> confirmed){
showTextInput(title, text, textLength, def, false, confirmed);
}
public void showTextInput(String title, String text, int textLength, String def, boolean numeric, Cons<String> confirmed){
showTextInput(title, text, textLength, def, numeric, confirmed, () -> {});
}
public void showInfoFade(String info){
showInfoFade(info, 7f);
}
public void showInfoFade(String info, float duration){
var cinfo = Core.scene.find("coreinfo");
Table table = new Table();
table.touchable = Touchable.disabled;
table.setFillParent(true);
table.actions(Actions.fadeOut(7f, Interp.fade), Actions.remove());
if(cinfo.visible && !state.isMenu()) table.marginTop(cinfo.getPrefHeight() / Scl.scl() / 2);
table.actions(Actions.fadeOut(duration, Interp.fade), Actions.remove());
table.top().add(info).style(Styles.outlineLabel).padTop(10);
Core.scene.add(table);
}
public void addDescTooltip(Element elem, String description){
if(description == null) return;
elem.addListener(new Tooltip(t -> t.background(Styles.black8).margin(4f).add(description).color(Color.lightGray)){
{
allowMobile = true;
}
@Override
protected void setContainerPosition(Element element, float x, float y){
this.targetActor = element;
Vec2 pos = element.localToStageCoordinates(Tmp.v1.set(0, 0));
container.pack();
container.setPosition(pos.x, pos.y, Align.topLeft);
container.setOrigin(0, element.getHeight());
}
});
}
/** Shows a fading label at the top of the screen. */
public void showInfoToast(String info, float duration){
var cinfo = Core.scene.find("coreinfo");
Table table = new Table();
table.setFillParent(true);
table.touchable = Touchable.disabled;
table.setFillParent(true);
if(cinfo.visible && !state.isMenu()) table.marginTop(cinfo.getPrefHeight() / Scl.scl() / 2);
table.update(() -> {
if(state.isMenu()) table.remove();
});
table.actions(Actions.delay(duration * 0.9f), Actions.fadeOut(duration * 0.1f, Interp.fade), Actions.remove());
table.top().table(Styles.black3, t -> t.margin(4).add(info).style(Styles.outlineLabel)).padTop(10);
Core.scene.add(table);
lastAnnouncement = table;
}
/** Shows a label at some position on the screen. Does not fade. */
@@ -348,17 +419,21 @@ public class UI implements ApplicationListener, Loadable{
}
public void showInfo(String info){
showInfo(info, () -> {});
}
public void showInfo(String info, Runnable listener){
new Dialog(""){{
getCell(cont).growX();
cont.margin(15).add(info).width(400f).wrap().get().setAlignment(Align.center, Align.center);
buttons.button("@ok", () -> {
hide();
listener.run();
}).size(110, 50).pad(4);
buttons.button("@ok", this::hide).size(110, 50).pad(4);
keyDown(KeyCode.enter, this::hide);
closeOnBack();
}}.show();
}
public void showInfoOnHidden(String info, Runnable listener){
new Dialog(""){{
getCell(cont).growX();
cont.margin(15).add(info).width(400f).wrap().get().setAlignment(Align.center, Align.center);
buttons.button("@ok", this::hide).size(110, 50).pad(4);
hidden(listener);
closeOnBack();
}}.show();
}
@@ -392,6 +467,8 @@ public class UI implements ApplicationListener, Loadable{
}
public void showException(String text, Throwable exc){
if(loadfrag == null) return;
loadfrag.hide();
new Dialog(""){{
String message = Strings.getFinalMessage(exc);
@@ -449,6 +526,10 @@ public class UI implements ApplicationListener, Loadable{
}}.show();
}
public void showConfirm(String text, Runnable confirmed){
showConfirm("@confirm", text, null, confirmed);
}
public void showConfirm(String title, String text, Runnable confirmed){
showConfirm(title, text, null, confirmed);
}
@@ -458,8 +539,8 @@ public class UI implements ApplicationListener, Loadable{
dialog.cont.add(text).width(mobile ? 400f : 500f).wrap().pad(4f).get().setAlignment(Align.center, Align.center);
dialog.buttons.defaults().size(200f, 54f).pad(2f);
dialog.setFillParent(false);
dialog.buttons.button("@cancel", dialog::hide);
dialog.buttons.button("@ok", () -> {
dialog.buttons.button("@cancel", Icon.cancel, dialog::hide);
dialog.buttons.button("@ok", Icon.ok, () -> {
dialog.hide();
confirmed.run();
});
@@ -497,6 +578,10 @@ public class UI implements ApplicationListener, Loadable{
dialog.show();
}
public boolean hasAnnouncement(){
return lastAnnouncement != null && lastAnnouncement.parent != null;
}
/** Display text in the middle of the screen, then fade out. */
public void announce(String text){
announce(text, 3);
@@ -512,6 +597,7 @@ public class UI implements ApplicationListener, Loadable{
t.pack();
t.act(0.1f);
Core.scene.add(t);
lastAnnouncement = t;
}
public void showOkText(String title, String text, Runnable confirmed){
@@ -526,17 +612,106 @@ public class UI implements ApplicationListener, Loadable{
dialog.show();
}
//TODO move?
// TODO REPLACE INTEGER WITH arc.fun.IntCons(int, T) or something like that.
public Dialog newMenuDialog(String title, String message, String[][] options, Cons2<Integer, Dialog> buttonListener){
return new Dialog(title){{
setFillParent(true);
removeChild(titleTable);
cont.add(titleTable).width(400f);
public static String formatAmount(int number){
if(number >= 1_000_000_000){
return Strings.fixed(number / 1_000_000_000f, 1) + "[gray]" + Core.bundle.get("unit.billions") + "[]";
}else if(number >= 1_000_000){
return Strings.fixed(number / 1_000_000f, 1) + "[gray]" + Core.bundle.get("unit.millions") + "[]";
}else if(number >= 10_000){
return number / 1000 + "[gray]" + Core.bundle.get("unit.thousands") + "[]";
}else if(number >= 1000){
return Strings.fixed(number / 1000f, 1) + "[gray]" + Core.bundle.get("unit.thousands") + "[]";
cont.row();
cont.image().width(400f).pad(2).colspan(2).height(4f).color(Pal.accent).bottom();
cont.row();
cont.pane(table -> {
table.add(message).width(400f).wrap().get().setAlignment(Align.center);
table.row();
int option = 0;
for(var optionsRow : options){
if(optionsRow.length == 0) continue;
Table buttonRow = table.row().table().get().row();
int fullWidth = 400 - (optionsRow.length - 1) * 8; // adjust to count padding as well
int width = fullWidth / optionsRow.length;
int lastWidth = fullWidth - width * (optionsRow.length - 1); // take the rest of space for uneven table
for(int i = 0; i < optionsRow.length; i++){
if(optionsRow[i] == null) continue;
String optionName = optionsRow[i];
int finalOption = option;
buttonRow.button(optionName, () -> buttonListener.get(finalOption, this))
.size(i == optionsRow.length - 1 ? lastWidth : width, 50).pad(4);
option++;
}
}
}).growX();
}};
}
/** Shows a menu that fires a callback when an option is selected. If nothing is selected, -1 is returned. */
public void showMenu(String title, String message, String[][] options, Intc callback){
Dialog dialog = newMenuDialog(title, message, options, (option, myself) -> {
callback.get(option);
myself.hide();
});
dialog.closeOnBack(() -> callback.get(-1));
dialog.show();
}
/** Shows a menu that hides when another followUp-menu is shown or when nothing is selected.
* @see UI#showMenu(String, String, String[][], Intc) */
public void showFollowUpMenu(int menuId, String title, String message, String[][] options, Intc callback) {
Dialog dialog = newMenuDialog(title, message, options, (option, myself) -> callback.get(option));
dialog.closeOnBack(() -> {
followUpMenus.remove(menuId);
callback.get(-1);
});
Dialog oldDialog = followUpMenus.remove(menuId);
if(oldDialog != null){
dialog.show(Core.scene, null);
oldDialog.hide(null);
}else{
dialog.show();
}
followUpMenus.put(menuId, dialog);
}
public void hideFollowUpMenu(int menuId) {
if(!followUpMenus.containsKey(menuId)) return;
followUpMenus.remove(menuId).hide();
}
/** Formats time with hours:minutes:seconds. */
public static String formatTime(float ticks){
int seconds = (int)(ticks / 60);
if(seconds < 60) return "0:" + (seconds < 10 ? "0" : "") + seconds;
int minutes = seconds / 60;
int modSec = seconds % 60;
if(minutes < 60) return minutes + ":" + (modSec < 10 ? "0" : "") + modSec;
int hours = minutes / 60;
int modMinute = minutes % 60;
return hours + ":" + (modMinute < 10 ? "0" : "") + modMinute + ":" + (modSec < 10 ? "0" : "") + modSec;
}
public static String formatAmount(long number){
//prevent things like bars displaying erroneous representations of casted infinities
if(number == Long.MAX_VALUE) return "";
if(number == Long.MIN_VALUE) return "-∞";
long mag = Math.abs(number);
String sign = number < 0 ? "-" : "";
if(mag >= 1_000_000_000){
return sign + Strings.fixed(mag / 1_000_000_000f, 1) + "[gray]" + billions + "[]";
}else if(mag >= 1_000_000){
return sign + Strings.fixed(mag / 1_000_000f, 1) + "[gray]" + millions + "[]";
}else if(mag >= 10_000){
return number / 1000 + "[gray]" + thousands + "[]";
}else if(mag >= 1000){
return sign + Strings.fixed(mag / 1000f, 1) + "[gray]" + thousands + "[]";
}else{
return number + "";
}
+10 -2
View File
@@ -12,6 +12,8 @@ public class Version{
public static String type = "unknown";
/** Build modifier, e.g. 'alpha' or 'release' */
public static String modifier = "unknown";
/** Git commit hash (short) */
public static String commitHash = "unknown";
/** Number specifying the major version, e.g. '4' */
public static int number;
/** Build number, e.g. '43'. set to '-1' for custom builds. */
@@ -32,6 +34,7 @@ public class Version{
type = map.get("type");
number = Integer.parseInt(map.get("number", "4"));
modifier = map.get("modifier");
commitHash = map.get("commitHash");
if(map.get("build").contains(".")){
String[] split = map.get("build").split("\\.");
try{
@@ -46,8 +49,13 @@ public class Version{
}
}
/** @return whether the version is greater than the specified version string, e.g. "120.1"*/
/** @return whether the current game version is greater than the specified version string, e.g. "120.1"*/
public static boolean isAtLeast(String str){
return isAtLeast(build, revision, str);
}
/** @return whether the version numbers are greater than the specified version string, e.g. "120.1"*/
public static boolean isAtLeast(int build, int revision, String str){
if(build <= 0 || str == null || str.isEmpty()) return true;
int dot = str.indexOf('.');
@@ -68,6 +76,6 @@ public class Version{
if(build == -1){
return "custom build";
}
return (type.equals("official") ? modifier : type) + " build " + build + (revision == 0 ? "" : "." + revision);
return (type.equals("official") ? modifier : type) + " build " + build + (revision == 0 ? "" : "." + revision) + (commitHash.equals("unknown") ? "" : " (" + commitHash + ")");
}
}
+165 -151
View File
@@ -4,10 +4,11 @@ import arc.*;
import arc.func.*;
import arc.math.*;
import arc.math.geom.*;
import arc.math.geom.Geometry.*;
import arc.struct.*;
import arc.struct.ObjectIntMap.*;
import arc.util.*;
import arc.util.noise.*;
import mindustry.*;
import mindustry.content.*;
import mindustry.core.GameState.*;
import mindustry.ctype.*;
@@ -20,7 +21,6 @@ import mindustry.maps.*;
import mindustry.maps.filters.*;
import mindustry.maps.filters.GenerateFilter.*;
import mindustry.type.*;
import mindustry.type.Weather.*;
import mindustry.world.*;
import mindustry.world.blocks.environment.*;
import mindustry.world.blocks.legacy.*;
@@ -31,12 +31,20 @@ public class World{
public final Context context = new Context();
public Tiles tiles = new Tiles(0, 0);
/** The number of times tiles have changed in this session. Used for blocks that need to poll world state, but not frequently. */
public int tileChanges = -1;
private boolean generating, invalidMap;
private ObjectMap<Map, Runnable> customMapLoaders = new ObjectMap<>();
public World(){
Events.on(TileChangeEvent.class, e -> {
tileChanges ++;
});
Events.on(WorldLoadEvent.class, e -> {
tileChanges = -1;
});
}
/** Adds a custom handler function for loading a custom map - usually a generated one. */
@@ -162,7 +170,11 @@ public class World{
return Math.round(coord / tilesize);
}
private void clearTileEntities(){
public int packArray(int x, int y){
return x + y * tiles.width;
}
public void clearBuildings(){
for(Tile tile : tiles){
if(tile != null && tile.build != null){
tile.build.remove();
@@ -175,7 +187,7 @@ public class World{
* Only use for loading saves!
*/
public Tiles resize(int width, int height){
clearTileEntities();
clearBuildings();
if(tiles.width != width || tiles.height != height){
tiles = new Tiles(width, height);
@@ -190,6 +202,7 @@ public class World{
*/
public void beginMapLoad(){
generating = true;
Events.fire(new WorldLoadBeginEvent());
}
/**
@@ -197,6 +210,7 @@ public class World{
* A WorldLoadEvent will be fire.
*/
public void endMapLoad(){
Events.fire(new WorldLoadEndEvent());
for(Tile tile : tiles){
//remove legacy blocks; they need to stop existing
@@ -219,7 +233,7 @@ public class World{
}
public Rect getQuadBounds(Rect in){
return in.set(-finalWorldBounds, -finalWorldBounds, world.width() * tilesize + finalWorldBounds * 2, world.height() * tilesize + finalWorldBounds * 2);
return in.set(-finalWorldBounds, -finalWorldBounds, width() * tilesize + finalWorldBounds * 2, height() * tilesize + finalWorldBounds * 2);
}
public void setGenerating(boolean gen){
@@ -240,98 +254,80 @@ public class World{
}
public void loadSector(Sector sector){
setSectorRules(sector);
loadSector(sector, 0, true);
}
public void loadSector(Sector sector, int seedOffset, boolean saveInfo){
setSectorRules(sector, saveInfo);
int size = sector.getSize();
loadGenerator(size, size, tiles -> {
if(sector.preset != null){
sector.preset.generator.generate(tiles);
sector.preset.rules.get(state.rules); //apply extra rules
}else if(sector.planet.generator != null){
sector.planet.generator.generate(tiles, sector, seedOffset);
}else{
sector.planet.generator.generate(tiles, sector);
throw new RuntimeException("Sector " + sector.id + " on planet " + sector.planet.name + " has no generator or preset defined. Provide a planet generator or preset map.");
}
//just in case
state.rules.sector = sector;
});
if(saveInfo && state.rules.waves){
sector.info.waves = state.rules.waves;
}
//postgenerate for bases
if(sector.preset == null){
if(sector.preset == null && sector.planet.generator != null){
sector.planet.generator.postGenerate(tiles);
}
//reset rules
setSectorRules(sector);
setSectorRules(sector, saveInfo);
if(state.rules.defaultTeam.core() != null){
sector.info.spawnPosition = state.rules.defaultTeam.core().pos();
}
}
private void setSectorRules(Sector sector){
private void setSectorRules(Sector sector, boolean saveInfo){
state.map = new Map(StringMap.of("name", sector.preset == null ? sector.planet.localizedName + "; Sector " + sector.id : sector.preset.localizedName));
state.rules.sector = sector;
state.rules.weather.clear();
//apply weather based on terrain
ObjectIntMap<Block> floorc = new ObjectIntMap<>();
sector.planet.generator.addWeather(sector, state.rules);
ObjectSet<UnlockableContent> content = new ObjectSet<>();
for(Tile tile : world.tiles){
if(world.getDarkness(tile.x, tile.y) >= 3){
//resources can be outside area
boolean border = state.rules.limitMapArea;
state.rules.limitMapArea = false;
//TODO duplicate code?
for(Tile tile : tiles){
if(getDarkness(tile.x, tile.y) >= 3){
continue;
}
Liquid liquid = tile.floor().liquidDrop;
if(tile.floor().itemDrop != null) content.add(tile.floor().itemDrop);
if(tile.overlay().itemDrop != null) content.add(tile.overlay().itemDrop);
if(tile.floor().itemDrop != null && tile.block() == Blocks.air) content.add(tile.floor().itemDrop);
if(tile.overlay().itemDrop != null && tile.block() == Blocks.air) content.add(tile.overlay().itemDrop);
if(tile.wallDrop() != null) content.add(tile.wallDrop());
if(liquid != null) content.add(liquid);
if(!tile.block().isStatic()){
floorc.increment(tile.floor());
if(tile.overlay() != Blocks.air){
floorc.increment(tile.overlay());
}
}
}
state.rules.limitMapArea = border;
//sort counts in descending order
Seq<Entry<Block>> entries = floorc.entries().toArray();
entries.sort(e -> -e.value);
//remove all blocks occuring < 30 times - unimportant
entries.removeAll(e -> e.value < 30);
Block[] floors = new Block[entries.size];
for(int i = 0; i < entries.size; i++){
floors[i] = entries.get(i).key;
}
//TODO bad code
boolean hasSnow = floors[0].name.contains("ice") || floors[0].name.contains("snow");
boolean hasRain = !hasSnow && content.contains(Liquids.water) && !floors[0].name.contains("sand");
boolean hasDesert = !hasSnow && !hasRain && floors[0] == Blocks.sand;
boolean hasSpores = floors[0].name.contains("spore") || floors[0].name.contains("moss") || floors[0].name.contains("tainted");
if(hasSnow){
state.rules.weather.add(new WeatherEntry(Weathers.snow));
}
if(hasRain){
state.rules.weather.add(new WeatherEntry(Weathers.rain));
state.rules.weather.add(new WeatherEntry(Weathers.fog));
}
if(hasDesert){
state.rules.weather.add(new WeatherEntry(Weathers.sandstorm));
}
if(hasSpores){
state.rules.weather.add(new WeatherEntry(Weathers.sporestorm));
}
sector.info.resources = content.asArray();
state.rules.cloudColor = sector.planet.landCloudColor;
state.rules.env = sector.planet.defaultEnv;
state.rules.planet = sector.planet;
sector.planet.applyRules(state.rules);
sector.info.resources = content.toSeq();
sector.info.resources.sort(Structs.comps(Structs.comparing(Content::getContentType), Structs.comparingInt(c -> c.id)));
sector.saveInfo();
if(saveInfo){
sector.saveInfo();
}
}
public Context filterContext(Map map){
@@ -367,18 +363,18 @@ public class World{
invalidMap = false;
if(!headless){
if(state.teams.playerCores().size == 0 && !checkRules.pvp){
ui.showErrorMessage("@map.nospawn");
if(state.teams.cores(checkRules.defaultTeam).size == 0 && !checkRules.pvp){
invalidMap = true;
ui.showErrorMessage(Core.bundle.format("map.nospawn", checkRules.defaultTeam.coloredName()));
}else if(checkRules.pvp){ //pvp maps need two cores to be valid
if(state.teams.getActive().count(TeamData::hasCore) < 2){
invalidMap = true;
ui.showErrorMessage("@map.nospawn.pvp");
}
}else if(checkRules.attackMode){ //attack maps need two cores to be valid
invalidMap = state.teams.get(state.rules.waveTeam).noCores();
invalidMap = state.rules.waveTeam.data().noCores();
if(invalidMap){
ui.showErrorMessage("@map.nospawn.attack");
ui.showErrorMessage(Core.bundle.format("map.nospawn.attack", checkRules.waveTeam.coloredName()));
}
}
}else{
@@ -392,78 +388,11 @@ public class World{
if(invalidMap) Core.app.post(() -> state.set(State.menu));
}
public void notifyChanged(Tile tile){
if(!generating){
Core.app.post(() -> Events.fire(new TileChangeEvent(tile)));
}
}
public void raycastEachWorld(float x0, float y0, float x1, float y1, Raycaster cons){
raycastEach(toTile(x0), toTile(y0), toTile(x1), toTile(y1), cons);
}
public void raycastEach(int x0f, int y0f, int x1, int y1, Raycaster cons){
int x0 = x0f;
int y0 = y0f;
int dx = Math.abs(x1 - x0);
int dy = Math.abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1;
int sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
int e2;
while(true){
if(cons.accept(x0, y0)) break;
if(x0 == x1 && y0 == y1) break;
e2 = 2 * err;
if(e2 > -dy){
err = err - dy;
x0 = x0 + sx;
}
if(e2 < dx){
err = err + dx;
y0 = y0 + sy;
}
}
}
public boolean raycast(int x0f, int y0f, int x1, int y1, Raycaster cons){
int x0 = x0f;
int y0 = y0f;
int dx = Math.abs(x1 - x0);
int dy = Math.abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1;
int sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
int e2;
while(true){
if(cons.accept(x0, y0)) return true;
if(x0 == x1 && y0 == y1) return false;
e2 = 2 * err;
if(e2 > -dy){
err = err - dy;
x0 = x0 + sx;
}
if(e2 < dx){
err = err + dx;
y0 = y0 + sy;
}
}
}
public void addDarkness(Tiles tiles){
byte[] dark = new byte[tiles.width * tiles.height];
byte[] writeBuffer = new byte[tiles.width * tiles.height];
byte darkIterations = 4;
byte darkIterations = darkRadius;
for(int i = 0; i < dark.length; i++){
Tile tile = tiles.geti(i);
@@ -497,7 +426,7 @@ public class World{
tile.data = dark[idx];
}
if(dark[idx] == 4){
if(dark[idx] == darkRadius){
boolean full = true;
for(Point2 p : Geometry.d4){
int px = p.x + tile.x, py = p.y + tile.y;
@@ -508,22 +437,60 @@ public class World{
}
}
if(full) tile.data = 5;
if(full) tile.data = darkRadius + 1;
}
}
}
public float getDarkness(int x, int y){
int edgeBlend = 2;
public byte getWallDarkness(Tile tile){
if(tile.isDarkened()){
int minDst = darkRadius + 1;
for(int cx = tile.x - darkRadius; cx <= tile.x + darkRadius; cx++){
for(int cy = tile.y - darkRadius; cy <= tile.y + darkRadius; cy++){
if(tiles.in(cx, cy) && !rawTile(cx, cy).isDarkened()){
minDst = Math.min(minDst, Math.abs(cx - tile.x) + Math.abs(cy - tile.y));
}
}
}
return (byte)Math.max((minDst - 1), 0);
}
return 0;
}
public void checkMapArea(){
for(var build : Groups.build){
//reset map-area-based disabled blocks.
if(!build.enabled && build.block.autoResetEnabled){
build.enabled = true;
}
}
}
//TODO optimize; this is very slow and called too often!
public float getDarkness(int x, int y){
float dark = 0;
int edgeDst = Math.min(x, Math.min(y, Math.min(Math.abs(x - (tiles.width - 1)), Math.abs(y - (tiles.height - 1)))));
if(edgeDst <= edgeBlend){
dark = Math.max((edgeBlend - edgeDst) * (4f / edgeBlend), dark);
if(Vars.state.rules.borderDarkness){
int edgeBlend = 2;
int edgeDst;
if(!state.rules.limitMapArea){
edgeDst = Math.min(x, Math.min(y, Math.min(-(x - (tiles.width - 1)), -(y - (tiles.height - 1)))));
}else{
edgeDst =
Math.min(x - state.rules.limitX,
Math.min(y - state.rules.limitY,
Math.min(-(x - (state.rules.limitX + state.rules.limitWidth - 1)), -(y - (state.rules.limitY + state.rules.limitHeight - 1)))));
}
if(edgeDst <= edgeBlend){
dark = Math.max((edgeBlend - edgeDst) * (4f / edgeBlend), dark);
}
}
if(state.hasSector() && state.getSector().preset == null){
int circleBlend = 14;
int circleBlend = 5;
//quantized angle
float offset = state.getSector().rect.rotation + 90;
float angle = Angles.angle(x, y, tiles.width/2, tiles.height/2) + offset;
@@ -546,22 +513,65 @@ public class World{
}
}
Tile tile = world.tile(x, y);
if(tile != null && tile.block().solid && tile.block().fillsTile && !tile.block().synthetic()){
Tile tile = tile(x, y);
if(tile != null && tile.isDarkened()){
dark = Math.max(dark, tile.data);
}
return dark;
}
public interface Raycaster{
boolean accept(int x, int y);
public static void raycastEachWorld(float x0, float y0, float x1, float y1, Raycaster cons){
raycastEach(toTile(x0), toTile(y0), toTile(x1), toTile(y1), cons);
}
public static void raycastEach(int x1, int y1, int x2, int y2, Raycaster cons){
int x = x1, dx = Math.abs(x2 - x), sx = x < x2 ? 1 : -1;
int y = y1, dy = Math.abs(y2 - y), sy = y < y2 ? 1 : -1;
int e2, err = dx - dy;
while(true){
if(cons.accept(x, y)) break;
if(x == x2 && y == y2) break;
e2 = 2 * err;
if(e2 > -dy){
err -= dy;
x += sx;
}
if(e2 < dx){
err += dx;
y += sy;
}
}
}
public static boolean raycast(int x1, int y1, int x2, int y2, Raycaster cons){
int x = x1, dx = Math.abs(x2 - x), sx = x < x2 ? 1 : -1;
int y = y1, dy = Math.abs(y2 - y), sy = y < y2 ? 1 : -1;
int e2, err = dx - dy;
while(true){
if(cons.accept(x, y)) return true;
if(x == x2 && y == y2) return false;
e2 = 2 * err;
if(e2 > -dy){
err = err - dy;
x = x + sx;
}
if(e2 < dx){
err = err + dx;
y = y + sy;
}
}
}
private class Context implements WorldContext{
Context(){
}
Context(){}
@Override
public Tile tile(int index){
@@ -597,15 +607,21 @@ public class World{
}
/** World context that applies filters after generation end. */
private class FilterContext extends Context{
public class FilterContext extends Context{
final Map map;
FilterContext(Map map){
public FilterContext(Map map){
this.map = map;
}
@Override
public void end(){
applyFilters();
super.end();
}
public void applyFilters(){
Seq<GenerateFilter> filters = map.filters();
if(!filters.isEmpty()){
@@ -614,12 +630,10 @@ public class World{
for(GenerateFilter filter : filters){
filter.randomize();
input.begin(filter, width(), height(), (x, y) -> tiles.getn(x, y));
input.begin(width(), height(), (x, y) -> tiles.getn(x, y));
filter.apply(tiles, input);
}
}
super.end();
}
}
}
+16 -5
View File
@@ -6,8 +6,8 @@ import mindustry.*;
import mindustry.mod.Mods.*;
/** Base class for a content type that is loaded in {@link mindustry.core.ContentLoader}. */
public abstract class Content implements Comparable<Content>, Disposable{
public final short id;
public abstract class Content implements Comparable<Content>{
public short id;
/** Info on which mod this content was loaded from. */
public ModContentInfo minfo = new ModContentInfo();
@@ -25,20 +25,31 @@ public abstract class Content implements Comparable<Content>, Disposable{
/** Called after all content and modules are created. Do not use to load regions or texture data! */
public void init(){}
/** Called after init(). */
public void postInit(){}
/**
* Called after all content is created, only on non-headless versions.
* Use for loading regions or other image data.
*/
public void load(){}
/** Called right before load(). */
public void loadIcon(){}
/** @return whether an error occurred during mod loading. */
public boolean hasErrored(){
return minfo.error != null;
}
@Override
public void dispose(){
//does nothing by default
/** @return whether this is content from the base game. */
public boolean isVanilla(){
return minfo.mod == null;
}
/** @return whether this content is from a mod. */
public boolean isModded(){
return !isVanilla();
}
@Override
@@ -1,7 +0,0 @@
package mindustry.ctype;
/** Interface for a list of content to be loaded in {@link mindustry.core.ContentLoader}. */
public interface ContentList{
/** This method should create all the content. */
void load();
}
+30 -15
View File
@@ -1,22 +1,37 @@
package mindustry.ctype;
import arc.util.*;
import mindustry.ai.*;
import mindustry.entities.bullet.*;
import mindustry.type.*;
import mindustry.world.*;
/** Do not rearrange, ever! */
public enum ContentType{
item,
block,
mech_UNUSED,
bullet,
liquid,
status,
unit,
weather,
effect_UNUSED,
sector,
loadout_UNUSED,
typeid_UNUSED,
error,
planet,
ammo;
item(Item.class),
block(Block.class),
mech_UNUSED(null),
bullet(BulletType.class),
liquid(Liquid.class),
status(StatusEffect.class),
unit(UnitType.class),
weather(Weather.class),
effect_UNUSED(null),
sector(SectorPreset.class),
loadout_UNUSED(null),
typeid_UNUSED(null),
error(null),
planet(Planet.class),
ammo_UNUSED(null),
team(TeamEntry.class),
unitCommand(UnitCommand.class),
unitStance(UnitStance.class);
public static final ContentType[] all = values();
public final @Nullable Class<? extends Content> contentClass;
ContentType(Class<? extends Content> contentClass){
this.contentClass = contentClass;
}
}
+137 -35
View File
@@ -2,14 +2,18 @@ package mindustry.ctype;
import arc.*;
import arc.func.*;
import arc.graphics.*;
import arc.graphics.g2d.*;
import arc.graphics.g2d.TextureAtlas.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import mindustry.annotations.Annotations.*;
import mindustry.content.*;
import mindustry.content.TechTree.*;
import mindustry.game.EventType.*;
import mindustry.graphics.*;
import mindustry.graphics.MultiPacker.*;
import mindustry.type.*;
import mindustry.ui.*;
import mindustry.world.meta.*;
@@ -28,11 +32,38 @@ public abstract class UnlockableContent extends MappableContent{
public boolean alwaysUnlocked = false;
/** Whether to show the description in the research dialog preview. */
public boolean inlineDescription = true;
/** Special logic icon ID. */
public int iconId = 0;
/** Icons by Cicon ID.*/
protected TextureRegion[] cicons = new TextureRegion[Cicon.all.length];
/** Unlock state. Loaded from settings. Do not modify outside of the constructor. */
/** Whether details are hidden in custom games if this hasn't been unlocked in campaign mode. */
public boolean hideDetails = true;
/** Whether this is hidden from the Core Database. */
public boolean hideDatabase = false;
/** If false, all icon generation is disabled for this content; createIcons is not called. */
public boolean generateIcons = true;
/** How big the content appears in certain selection menus */
public float selectionSize = 24f;
/** Icon of the content to use in UI. */
public TextureRegion uiIcon;
/** Icon of the full content. Unscaled.*/
public TextureRegion fullIcon;
/** Override for the full icon. Useful for mod content with duplicate icons. Overrides any other full icon.*/
public String fullOverride = "";
/** If true, this content will appear in all database tabs. */
public boolean allDatabaseTabs = false;
/**
* Planets that this content is made for. If empty, a planet is decided based on item requirements.
* Currently, this is only meaningful for blocks.
* */
public ObjectSet<Planet> shownPlanets = new ObjectSet<>();
/**
* Content - usually a planet - that dictates which database tab(s) this content will appear in.
* If nothing is defined, it will use the values in shownPlanets.
* If shownPlanets is also empty, it will use Serpulo as the "default" tab.
* */
public ObjectSet<UnlockableContent> databaseTabs = new ObjectSet<>();
/** The tech tree node for this content, if applicable. Null if not part of a tech tree. */
public @Nullable TechNode techNode;
/** Tech nodes for all trees that this content is part of. */
public Seq<TechNode> techNodes = new Seq<>();
/** Unlock state. Loaded from settings. Do not modify outside the constructor. */
protected boolean unlocked;
public UnlockableContent(String name){
@@ -44,13 +75,36 @@ public abstract class UnlockableContent extends MappableContent{
this.unlocked = Core.settings != null && Core.settings.getBool(this.name + "-unlocked", false);
}
/** @return the tech node for this content. may be null. */
public @Nullable TechNode node(){
return TechTree.get(this);
@Override
public void postInit(){
super.postInit();
databaseTabs.addAll(shownPlanets);
}
@Override
public void loadIcon(){
fullIcon =
Core.atlas.find(fullOverride == null ? "" : fullOverride,
Core.atlas.find(getContentType().name() + "-" + name + "-full",
Core.atlas.find(name + "-full",
Core.atlas.find(name,
Core.atlas.find(getContentType().name() + "-" + name,
Core.atlas.find(name + "1"))))));
uiIcon = Core.atlas.find(getContentType().name() + "-" + name + "-ui", fullIcon);
}
public boolean isOnPlanet(@Nullable Planet planet){
return planet == null || planet == Planets.sun || shownPlanets.isEmpty() || shownPlanets.contains(planet);
}
public int getLogicId(){
return logicVars.lookupLogicId(this);
}
public String displayDescription(){
return minfo.mod == null ? description : description + "\n" + Core.bundle.format("mod.display", minfo.mod.meta.displayName());
return minfo.mod == null ? description : description + "\n" + Core.bundle.format("mod.display", minfo.mod.meta.displayName);
}
/** Checks stat initialization state. Call before displaying stats. */
@@ -65,12 +119,50 @@ public abstract class UnlockableContent extends MappableContent{
public void setStats(){
}
/** Generate any special icons for this content. Called asynchronously.*/
/** Display any extra info after details. */
public void displayExtra(Table table){
}
/**
* Generate any special icons for this content. Called synchronously.
* No regions are loaded at this point; grab pixmaps from the packer.
* */
@CallSuper
public void createIcons(MultiPacker packer){
}
protected void makeOutline(PageType page, MultiPacker packer, TextureRegion region, boolean makeNew, Color outlineColor, int outlineRadius){
if(region instanceof AtlasRegion at && region.found()){
String name = at.name;
if(!makeNew || !packer.has(name + "-outline")){
String regName = name + (makeNew ? "-outline" : "");
if(packer.registerOutlined(regName)){
PixmapRegion base = Core.atlas.getPixmap(region);
var result = Pixmaps.outline(base, outlineColor, outlineRadius);
Drawf.checkBleed(result);
packer.add(page, regName, result);
result.dispose();
}
}
}
}
protected void makeOutline(MultiPacker packer, TextureRegion region, String name, Color outlineColor, int outlineRadius){
if(region.found() && packer.registerOutlined(name)){
PixmapRegion base = Core.atlas.getPixmap(region);
var result = Pixmaps.outline(base, outlineColor, outlineRadius);
Drawf.checkBleed(result);
packer.add(PageType.main, name, result);
result.dispose();
}
}
protected void makeOutline(MultiPacker packer, TextureRegion region, String name, Color outlineColor){
makeOutline(packer, region, name, outlineColor, 4);
}
/** @return items needed to research this content */
public ItemStack[] researchRequirements(){
return ItemStack.empty;
@@ -80,19 +172,13 @@ public abstract class UnlockableContent extends MappableContent{
return Fonts.getUnicodeStr(name);
}
/** Returns a specific content icon, or the region {contentType}-{name} if not found.*/
public TextureRegion icon(Cicon icon){
if(cicons[icon.ordinal()] == null){
cicons[icon.ordinal()] =
Core.atlas.find(getContentType().name() + "-" + name + "-" + icon.name(),
Core.atlas.find(getContentType().name() + "-" + name + "-full",
Core.atlas.find(name + "-" + icon.name(),
Core.atlas.find(name + "-full",
Core.atlas.find(name,
Core.atlas.find(getContentType().name() + "-" + name,
Core.atlas.find(name + "1")))))));
}
return cicons[icon.ordinal()];
public int emojiChar(){
return Fonts.getUnicode(name);
}
public boolean hasEmoji(){
return Fonts.hasUnicodeStr(name);
}
/** Iterates through any implicit dependencies of this content.
@@ -101,11 +187,6 @@ public abstract class UnlockableContent extends MappableContent{
}
/** This should show all necessary info about this content in the specified table. */
public void display(Table table){
}
/** Called when this content is unlocked. Use this to unlock other related content. */
public void onUnlock(){
}
@@ -115,6 +196,15 @@ public abstract class UnlockableContent extends MappableContent{
return false;
}
/** @return whether to show a notification toast when this is unlocked */
public boolean showUnlock(){
return true;
}
public boolean logicVisible(){
return !isHidden();
}
/** Makes this piece of content unlocked; if it already unlocked, nothing happens. */
public void unlock(){
if(!unlocked && !alwaysUnlocked){
@@ -134,9 +224,26 @@ public abstract class UnlockableContent extends MappableContent{
}
}
public boolean unlockedNowHost(){
return !state.isCampaign() || unlockedHost();
}
/** @return in multiplayer, whether this is unlocked for the host player, otherwise, whether it is unlocked for the local player (same as unlocked()) */
public boolean unlockedHost(){
return net != null && net.client() ?
alwaysUnlocked || state.rules.researched.contains(this) :
unlocked || alwaysUnlocked;
}
/** @return whether this content is unlocked, or the player is in a custom (non-campaign) game. */
public boolean unlockedNow(){
return unlocked() || !state.isCampaign();
}
public boolean unlocked(){
if(net != null && net.client()) return unlocked || alwaysUnlocked || state.rules.researched.contains(name);
return unlocked || alwaysUnlocked;
return net != null && net.client() ?
alwaysUnlocked || unlocked || state.rules.researched.contains(this) :
unlocked || alwaysUnlocked;
}
/** Locks this content again. */
@@ -147,11 +254,6 @@ public abstract class UnlockableContent extends MappableContent{
}
}
/** @return whether this content is unlocked, or the player is in a custom (non-campaign) game. */
public boolean unlockedNow(){
return unlocked() || !state.isCampaign();
}
public boolean locked(){
return !unlocked();
}
@@ -0,0 +1,210 @@
package mindustry.editor;
import arc.*;
import arc.func.*;
import arc.graphics.*;
import arc.graphics.g2d.*;
import arc.scene.style.*;
import arc.scene.ui.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import mindustry.ctype.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.type.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
import mindustry.world.*;
import static mindustry.Vars.*;
public class BannedContentDialog<T extends UnlockableContent> extends BaseDialog{
private final ContentType type;
private Table selectedTable;
private Table deselectedTable;
private ObjectSet<T> contentSet;
private final Boolf<T> pred;
private String contentSearch;
private Category selectedCategory;
private Seq<T> filteredContent;
public BannedContentDialog(String title, ContentType type, Boolf<T> pred){
super(title);
this.type = type;
this.pred = pred;
contentSearch = "";
selectedTable = new Table();
deselectedTable = new Table();
addCloseButton();
shown(this::build);
resized(this::build);
}
public void show(ObjectSet<T> contentSet){
this.contentSet = contentSet;
show();
}
public void build(){
cont.clear();
var cell = cont.table(t -> {
t.table(s -> {
s.label(() -> "@search").padRight(10);
var field = s.field(contentSearch, value -> {
contentSearch = value;
rebuildTables();
}).get();
s.button(Icon.cancel, Styles.emptyi, () -> {
contentSearch = "";
field.setText("");
rebuildTables();
}).padLeft(10f).size(35f);
});
if(type == ContentType.block){
t.row();
t.table(c -> {
c.marginTop(8f);
c.defaults().marginRight(4f);
for(Category category : Category.values()){
c.button(ui.getIcon(category.name()), Styles.squareTogglei, () -> {
if(selectedCategory == category){
selectedCategory = null;
}else{
selectedCategory = category;
}
rebuildTables();
}).size(45f).update(i -> i.setChecked(selectedCategory == category)).padLeft(4f);
}
c.add("").padRight(4f);
}).center();
}
});
cont.row();
if(!Core.graphics.isPortrait()) cell.colspan(2);
filteredContent = content.<T>getBy(type).select(pred);
if(!contentSearch.isEmpty()) filteredContent.removeAll(content -> !content.localizedName.toLowerCase().contains(contentSearch.toLowerCase()));
cont.table(table -> {
if(type == ContentType.block){
table.add("@bannedblocks").color(Color.valueOf("f25555")).padBottom(-1).top().row();
}else{
table.add("@bannedunits").color(Color.valueOf("f25555")).padBottom(-1).top().row();
}
table.image().color(Color.valueOf("f25555")).height(3f).padBottom(5f).fillX().expandX().top().row();
table.pane(table2 -> selectedTable = table2).fill().expand().row();
table.button("@addall", Icon.add, () -> {
contentSet.addAll(filteredContent);
rebuildTables();
}).disabled(button -> contentSet.toSeq().containsAll(filteredContent)).padTop(10f).bottom().fillX();
}).fill().expandY().uniform();
if(Core.graphics.isPortrait()) cont.row();
var cell2 = cont.table(table -> {
if(type == ContentType.block){
table.add("@unbannedblocks").color(Pal.accent).padBottom(-1).top().row();
}else{
table.add("@unbannedunits").color(Pal.accent).padBottom(-1).top().row();
}
table.image().color(Pal.accent).height(3f).padBottom(5f).fillX().top().row();
table.pane(table2 -> deselectedTable = table2).fill().expand().row();
table.button("@addall", Icon.add, () -> {
contentSet.removeAll(filteredContent);
rebuildTables();
}).disabled(button -> {
Seq<T> array = content.getBy(type);
array = array.copy();
array.removeAll(contentSet.toSeq());
return array.containsAll(filteredContent);
}).padTop(10f).bottom().fillX();
}).fill().expandY().uniform();
if(Core.graphics.isPortrait()){
cell2.padTop(10f);
}else{
cell2.padLeft(10f);
}
rebuildTables();
}
private void rebuildTables(){
filteredContent.clear();
filteredContent = content.getBy(type);
filteredContent = filteredContent.select(pred);
if(!contentSearch.isEmpty()) filteredContent.removeAll(content -> !content.localizedName.toLowerCase().contains(contentSearch.toLowerCase()));
if(type == ContentType.block){
filteredContent.removeAll(content -> selectedCategory != null && ((Block)content).category != selectedCategory);
}
rebuildTable(selectedTable, true);
rebuildTable(deselectedTable, false);
}
private void rebuildTable(Table table, boolean isSelected){
table.clear();
int cols;
if(Core.graphics.isPortrait()){
cols = Math.max(4, (int)((Core.graphics.getWidth() / Scl.scl() - 100f) / 50f));
}else{
cols = Math.max(4, (int)((Core.graphics.getWidth() / Scl.scl() - 300f) / 50f / 2));
}
if((isSelected && contentSet.isEmpty()) || (!isSelected && contentSet.size == content.<T>getBy(type).count(pred))){
table.add("@empty").width(50f * cols).padBottom(5f).get().setAlignment(Align.center);
}else{
Seq<T> array;
if(!isSelected){
array = content.getBy(type);
array = array.copy();
array.removeAll(contentSet.toSeq());
}else{
array = contentSet.toSeq();
}
array.sort();
array.removeAll(content -> !filteredContent.contains(content));
if(array.isEmpty()){
table.add("@empty").width(50f * cols).padBottom(5f).get().setAlignment(Align.center);
return;
}
int i = 0;
boolean requiresPad = true;
for(T content : array){
TextureRegion region = content.uiIcon;
ImageButton button = new ImageButton(Tex.whiteui, Styles.clearNonei);
button.getStyle().imageUp = new TextureRegionDrawable(region);
button.resizeImage(8 * 4f);
if(isSelected) button.clicked(() -> {
contentSet.remove(content);
rebuildTables();
});
else button.clicked(() -> {
contentSet.add(content);
rebuildTables();
});
table.add(button).size(50f).tooltip(content.localizedName);
if(++i % cols == 0){
table.row();
requiresPad = false;
}
}
if(requiresPad){
table.add("").padRight(50f * (cols - i));
}
}
}
}
+3 -6
View File
@@ -10,13 +10,8 @@ import mindustry.world.blocks.environment.*;
import static mindustry.Vars.*;
public class DrawOperation{
private MapEditor editor;
private LongSeq array = new LongSeq();
public DrawOperation(MapEditor editor){
this.editor = editor;
}
public boolean isEmpty(){
return array.isEmpty();
}
@@ -61,7 +56,9 @@ public class DrawOperation{
void setTile(Tile tile, byte type, short to){
editor.load(() -> {
if(type == OpType.floor.ordinal()){
tile.setFloor((Floor)content.block(to));
if(content.block(to) instanceof Floor floor){
tile.setFloor(floor);
}
}else if(type == OpType.block.ordinal()){
tile.getLinkedTiles(t -> editor.renderer.updatePoint(t.x, t.y));
+22 -9
View File
@@ -39,9 +39,14 @@ public class EditorTile extends Tile{
}
@Override
public void setBlock(Block type, Team team, int rotation){
public boolean isEditorTile(){
return true;
}
@Override
public void setBlock(Block type, Team team, int rotation, Prov<Building> entityprov){
if(skip()){
super.setBlock(type, team, rotation);
super.setBlock(type, team, rotation, entityprov);
return;
}
@@ -63,7 +68,7 @@ public class EditorTile extends Tile{
}
super.setBlock(type, team, rotation);
super.setBlock(type, team, rotation, entityprov);
}
@Override
@@ -77,7 +82,7 @@ public class EditorTile extends Tile{
op(OpType.team, (byte)getTeamID());
super.setTeam(team);
getLinkedTiles(t -> ui.editor.editor.renderer.updatePoint(t.x, t.y));
getLinkedTiles(t -> editor.renderer.updatePoint(t.x, t.y));
}
@Override
@@ -87,7 +92,7 @@ public class EditorTile extends Tile{
return;
}
if(!floor.hasSurface() && overlay.asFloor().needsSurface) return;
if(!floor.hasSurface() && overlay.asFloor().needsSurface && (overlay instanceof OreBlock || !floor.supportsOverlay)) return;
if(overlay() == overlay) return;
op(OpType.overlay, this.overlay.id);
super.setOverlay(overlay);
@@ -102,6 +107,15 @@ public class EditorTile extends Tile{
}
}
@Override
protected void firePreChanged(){
if(skip()){
super.firePreChanged();
}else{
update();
}
}
@Override
public void recache(){
if(skip()){
@@ -132,7 +146,6 @@ public class EditorTile extends Tile{
if(block.hasBuilding()){
build = entityprov.get().init(this, team, false, rotation);
build.cons = new ConsumeModule(build);
if(block.hasItems) build.items = new ItemModule();
if(block.hasLiquids) build.liquids(new LiquidModule());
if(block.hasPower) build.power(new PowerModule());
@@ -140,14 +153,14 @@ public class EditorTile extends Tile{
}
private void update(){
ui.editor.editor.renderer.updatePoint(x, y);
editor.renderer.updatePoint(x, y);
}
private boolean skip(){
return state.isGame() || ui.editor.editor.isLoading();
return state.isGame() || editor.isLoading() || world.isGenerating();
}
private void op(OpType type, short value){
ui.editor.editor.addTileOp(TileOp.get(x, y, (byte)type.ordinal(), value));
editor.addTileOp(TileOp.get(x, y, (byte)type.ordinal(), value));
}
}
+45 -17
View File
@@ -10,10 +10,12 @@ import mindustry.content.*;
import mindustry.game.*;
import mindustry.world.*;
import static mindustry.Vars.*;
public enum EditorTool{
zoom(KeyCode.v),
pick(KeyCode.i){
public void touched(MapEditor editor, int x, int y){
public void touched(int x, int y){
if(!Structs.inBounds(x, y, editor.width(), editor.height())) return;
Tile tile = editor.tile(x, y);
@@ -23,7 +25,7 @@ public enum EditorTool{
line(KeyCode.l, "replace", "orthogonal"){
@Override
public void touchedLine(MapEditor editor, int x1, int y1, int x2, int y2){
public void touchedLine(int x1, int y1, int x2, int y2){
//straight
if(mode == 1){
if(Math.abs(x2 - x1) > Math.abs(y2 - y1)){
@@ -44,14 +46,15 @@ public enum EditorTool{
});
}
},
pencil(KeyCode.b, "replace", "square", "drawteams"){
//the "under liquid" rendering is too buggy to make public
pencil(KeyCode.b, "replace", "square", "drawteams"/*, "underliquid"*/){
{
edit = true;
draggable = true;
}
@Override
public void touched(MapEditor editor, int x, int y){
public void touched(int x, int y){
if(mode == -1){
//normal mode
editor.drawBlocks(x, y);
@@ -60,10 +63,12 @@ public enum EditorTool{
editor.drawBlocksReplace(x, y);
}else if(mode == 1){
//square mode
editor.drawBlocks(x, y, true, tile -> true);
editor.drawBlocks(x, y, true, false, tile -> true);
}else if(mode == 2){
//draw teams
editor.drawCircle(x, y, tile -> tile.setTeam(editor.drawTeam));
}else if(mode == 3){
editor.drawBlocks(x, y, false, true, tile -> tile.floor().isLiquid);
}
}
@@ -75,7 +80,7 @@ public enum EditorTool{
}
@Override
public void touched(MapEditor editor, int x, int y){
public void touched(int x, int y){
editor.drawCircle(x, y, tile -> {
if(mode == -1){
//erase block
@@ -87,7 +92,7 @@ public enum EditorTool{
});
}
},
fill(KeyCode.g, "replaceall", "fillteams"){
fill(KeyCode.g, "replaceall", "fillteams", "fillerase"){
{
edit = true;
}
@@ -95,17 +100,19 @@ public enum EditorTool{
IntSeq stack = new IntSeq();
@Override
public void touched(MapEditor editor, int x, int y){
public void touched(int x, int y){
if(!Structs.inBounds(x, y, editor.width(), editor.height())) return;
Tile tile = editor.tile(x, y);
if(editor.drawBlock.isMultiblock()){
if(tile == null) return;
if(editor.drawBlock.isMultiblock() && (mode == 0 || mode == -1)){
//don't fill multiblocks, thanks
pencil.touched(editor, x, y);
pencil.touched(x, y);
return;
}
//mode 0 or 1, fill everything with the floor/tile or replace it
//mode 0 or standard, fill everything with the floor/tile or replace it
if(mode == 0 || mode == -1){
//can't fill parts or multiblocks
if(tile.block().isMultiblock()){
@@ -133,19 +140,40 @@ public enum EditorTool{
}
//replace only when the mode is 0 using the specified functions
fill(editor, x, y, mode == 0, tester, setter);
fill(x, y, mode == 0, tester, setter);
}else if(mode == 1){ //mode 1 is team fill
//only fill synthetic blocks, it's meaningless otherwise
if(tile.synthetic()){
Team dest = tile.team();
if(dest == editor.drawTeam) return;
fill(editor, x, y, false, t -> t.getTeamID() == dest.id && t.synthetic(), t -> t.setTeam(editor.drawTeam));
fill(x, y, true, t -> t.getTeamID() == dest.id && t.synthetic(), t -> t.setTeam(editor.drawTeam));
}
}else if(mode == 2){ //erase mode
Boolf<Tile> tester;
Cons<Tile> setter;
if(tile.block() != Blocks.air){
Block dest = tile.block();
tester = t -> t.block() == dest;
setter = t -> t.setBlock(Blocks.air);
}else if(tile.overlay() != Blocks.air){
Block dest = tile.overlay();
tester = t -> t.overlay() == dest;
setter = t -> t.setOverlay(Blocks.air);
}else{
//trying to erase floor (no)
tester = null;
setter = null;
}
if(setter != null){
fill(x, y, false, tester, setter);
}
}
}
void fill(MapEditor editor, int x, int y, boolean replace, Boolf<Tile> tester, Cons<Tile> filler){
void fill(int x, int y, boolean replace, Boolf<Tile> tester, Cons<Tile> filler){
int width = editor.width(), height = editor.height();
if(replace){
@@ -215,7 +243,7 @@ public enum EditorTool{
}
@Override
public void touched(MapEditor editor, int x, int y){
public void touched(int x, int y){
//floor spray
if(editor.drawBlock.isFloor()){
@@ -263,7 +291,7 @@ public enum EditorTool{
this.key = code;
}
public void touched(MapEditor editor, int x, int y){}
public void touched(int x, int y){}
public void touchedLine(MapEditor editor, int x1, int y1, int x2, int y2){}
public void touchedLine(int x1, int y1, int x2, int y2){}
}
+69 -16
View File
@@ -8,6 +8,7 @@ import arc.math.geom.*;
import arc.struct.*;
import mindustry.content.*;
import mindustry.editor.DrawOperation.*;
import mindustry.entities.units.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.io.*;
@@ -17,17 +18,17 @@ import mindustry.world.*;
import static mindustry.Vars.*;
public class MapEditor{
public static final int[] brushSizes = {1, 2, 3, 4, 5, 9, 15, 20};
public static final float[] brushSizes = {1, 1.5f, 2, 3, 4, 5, 9, 15, 20};
public StringMap tags = new StringMap();
public MapRenderer renderer = new MapRenderer(this);
public MapRenderer renderer = new MapRenderer();
private final Context context = new Context();
private OperationStack stack = new OperationStack();
private DrawOperation currentOp;
private boolean loading;
public int brushSize = 1;
public float brushSize = 1;
public int rotation;
public Block drawBlock = Blocks.stone;
public Team drawTeam = Team.sharded;
@@ -61,11 +62,31 @@ public class MapEditor{
public void beginEdit(Pixmap pixmap){
reset();
createTiles(pixmap.getWidth(), pixmap.getHeight());
createTiles(pixmap.width, pixmap.height);
load(() -> MapIO.readImage(pixmap, tiles()));
renderer.resize(width(), height());
}
public void updateRenderer(){
Tiles tiles = world.tiles;
Seq<Building> builds = new Seq<>();
for(int i = 0; i < tiles.width * tiles.height; i++){
Tile tile = tiles.geti(i);
var build = tile.build;
if(build != null && tile.isCenter()){
builds.add(build);
}
tiles.seti(i, new EditorTile(tile.x, tile.y, tile.floorID(), tile.overlayID(), build == null ? tile.blockID() : 0));
}
for(var build : builds){
tiles.get(build.tileX(), build.tileY()).setBlock(build.block, build.team, build.rotation, () -> build);
}
renderer.resize(width(), height());
}
public void load(Runnable r){
loading = true;
r.run();
@@ -115,14 +136,14 @@ public class MapEditor{
}
public void drawBlocks(int x, int y){
drawBlocks(x, y, false, tile -> true);
drawBlocks(x, y, false, false, tile -> true);
}
public void drawBlocks(int x, int y, Boolf<Tile> tester){
drawBlocks(x, y, false, tester);
drawBlocks(x, y, false, false, tester);
}
public void drawBlocks(int x, int y, boolean square, Boolf<Tile> tester){
public void drawBlocks(int x, int y, boolean square, boolean forceOverlay, Boolf<Tile> tester){
if(drawBlock.isMultiblock()){
x = Mathf.clamp(x, (drawBlock.size - 1) / 2, width() - drawBlock.size / 2 - 1);
y = Mathf.clamp(y, (drawBlock.size - 1) / 2, height() - drawBlock.size / 2 - 1);
@@ -136,7 +157,13 @@ public class MapEditor{
if(!tester.get(tile)) return;
if(isFloor){
tile.setFloor(drawBlock.asFloor());
if(forceOverlay){
tile.setOverlay(drawBlock.asFloor());
}else{
if(!(drawBlock.asFloor().wallOre && !tile.block().solid)){
tile.setFloor(drawBlock.asFloor());
}
}
}else if(!(tile.block().isMultiblock() && !drawBlock.isMultiblock())){
if(drawBlock.rotate && tile.build != null && tile.build.rotation != rotation){
addTileOp(TileOp.get(tile.x, tile.y, (byte)OpType.rotation.ordinal(), (byte)rotation));
@@ -226,8 +253,9 @@ public class MapEditor{
}
public void drawCircle(int x, int y, Cons<Tile> drawer){
for(int rx = -brushSize; rx <= brushSize; rx++){
for(int ry = -brushSize; ry <= brushSize; ry++){
int clamped = (int)brushSize;
for(int rx = -clamped; rx <= clamped; rx++){
for(int ry = -clamped; ry <= clamped; ry++){
if(Mathf.within(rx, ry, brushSize - 0.5f + 0.0001f)){
int wx = x + rx, wy = y + ry;
@@ -242,8 +270,9 @@ public class MapEditor{
}
public void drawSquare(int x, int y, Cons<Tile> drawer){
for(int rx = -brushSize; rx <= brushSize; rx++){
for(int ry = -brushSize; ry <= brushSize; ry++){
int clamped = (int)brushSize;
for(int rx = -clamped; rx <= clamped; rx++){
for(int ry = -clamped; ry <= clamped; ry++){
int wx = x + rx, wy = y + ry;
if(wx < 0 || wy < 0 || wx >= width() || wy >= height()){
@@ -255,27 +284,51 @@ public class MapEditor{
}
}
public void resize(int width, int height){
public void resize(int width, int height, int shiftX, int shiftY){
clearOp();
Tiles previous = world.tiles;
int offsetX = -(width - width()) / 2, offsetY = -(height - height()) / 2;
int offsetX = (width() - width) / 2 - shiftX, offsetY = (height() - height) / 2 - shiftY;
loading = true;
Tiles tiles = world.resize(width, height);
world.clearBuildings();
Tiles tiles = world.tiles = new Tiles(width, height);
for(int x = 0; x < width; x++){
for(int y = 0; y < height; y++){
int px = offsetX + x, py = offsetY + y;
if(previous.in(px, py)){
tiles.set(x, y, previous.getn(px, py));
Tile tile = tiles.getn(x, y);
Object config = null;
//fetch the old config first, configs can be relative to block position (tileX/tileY) before those are reassigned
if(tile.build != null && tile.isCenter()){
config = tile.build.config();
}
tile.x = (short)x;
tile.y = (short)y;
if(tile.build != null && tile.isCenter()){
tile.build.x = x * tilesize + tile.block().offset;
tile.build.y = y * tilesize + tile.block().offset;
//shift links to account for map resize
if(config != null){
Object out = BuildPlan.pointConfig(tile.block(), config, p -> {
if(!tile.build.block.ignoreResizeConfig){
p.sub(offsetX, offsetY);
}
});
if(out != config){
tile.build.configureAny(out);
}
}
}
}else{
tiles.set(x, y, new EditorTile(x, y, Blocks.stone.id, (short)0, (short)0));
}
@@ -319,7 +372,7 @@ public class MapEditor{
public void addTileOp(long data){
if(loading) return;
if(currentOp == null) currentOp = new DrawOperation(this);
if(currentOp == null) currentOp = new DrawOperation();
currentOp.addOperation(data);
renderer.updatePoint(TileOp.x(data), TileOp.y(data));
+116 -72
View File
@@ -19,6 +19,7 @@ import mindustry.*;
import mindustry.content.*;
import mindustry.core.GameState.*;
import mindustry.game.*;
import mindustry.game.MapObjectives.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.io.*;
@@ -33,18 +34,18 @@ import mindustry.world.meta.*;
import static mindustry.Vars.*;
public class MapEditorDialog extends Dialog implements Disposable{
public final MapEditor editor;
private MapView view;
private MapInfoDialog infoDialog;
private MapLoadDialog loadDialog;
private MapResizeDialog resizeDialog;
private MapGenerateDialog generateDialog;
private SectorGenerateDialog sectorGenDialog;
private MapPlayDialog playtestDialog;
private ScrollPane pane;
private BaseDialog menu;
private Table blockSelection;
private Rules lastSavedRules;
private boolean saved = false;
private boolean saved = false; //currently never read
private boolean shownWithMap = false;
private Seq<Block> blocksOut = new Seq<>();
@@ -53,10 +54,11 @@ public class MapEditorDialog extends Dialog implements Disposable{
background(Styles.black);
editor = new MapEditor();
view = new MapView(editor);
infoDialog = new MapInfoDialog(editor);
generateDialog = new MapGenerateDialog(editor, true);
view = new MapView();
infoDialog = new MapInfoDialog();
generateDialog = new MapGenerateDialog(true);
sectorGenDialog = new SectorGenerateDialog();
playtestDialog = new MapPlayDialog();
menu = new BaseDialog("@menu");
menu.addCloseButton();
@@ -120,9 +122,15 @@ public class MapEditorDialog extends Dialog implements Disposable{
"@editor.exportimage", "@editor.exportimage.description", Icon.fileImage,
(Runnable)() -> platform.export(editor.tags.get("name", "unknown"), "png", file -> {
Pixmap out = MapIO.writeImage(editor.tiles());
file.writePNG(out);
file.writePng(out);
out.dispose();
})));
t.row();
t.button("@editor.ingame", Icon.right, this::editInGame);
t.button("@editor.playtest", Icon.play, this::playtest);
});
menu.cont.row();
@@ -156,24 +164,31 @@ public class MapEditorDialog extends Dialog implements Disposable{
}
platform.publish(map);
}).padTop(-3).size(swidth * 2f + 10, 60f).update(b -> b.setText(editor.tags.containsKey("steamid") ? editor.tags.get("author").equals(player.name) ? "@workshop.listing" : "@view.workshop" : "@editor.publish.workshop"));
}).padTop(-3).size(swidth * 2f + 10, 60f).update(b ->
b.setText(editor.tags.containsKey("steamid") ?
editor.tags.get("author", "").equals(steamPlayerName) ? "@workshop.listing" : "@view.workshop" :
"@editor.publish.workshop"));
menu.cont.row();
}
menu.cont.button("@editor.ingame", Icon.right, this::playtest).padTop(!steam ? -3 : 1).size(swidth * 2f + 10, 60f);
menu.cont.button("@editor.sectorgenerate", Icon.terrain, () -> {
menu.hide();
sectorGenDialog.show();
}).padTop(!steam ? -3 : 1).size(swidth * 2f + 10, 60f);
menu.cont.row();
menu.cont.row();
menu.cont.button("@quit", Icon.exit, () -> {
tryExit();
menu.hide();
}).size(swidth * 2f + 10, 60f);
}).padTop(1).size(swidth * 2f + 10, 60f);
resizeDialog = new MapResizeDialog(editor, (x, y) -> {
if(!(editor.width() == x && editor.height() == y)){
resizeDialog = new MapResizeDialog((width, height, shiftX, shiftY) -> {
if(!(editor.width() == width && editor.height() == height && shiftX == 0 && shiftY == 0)){
ui.loadAnd(() -> {
editor.resize(x, y);
editor.resize(width, height, shiftX, shiftY);
});
}
});
@@ -193,11 +208,7 @@ public class MapEditorDialog extends Dialog implements Disposable{
margin(0);
update(() -> {
if(Core.scene.getKeyboardFocus() instanceof Dialog && Core.scene.getKeyboardFocus() != this){
return;
}
if(Core.scene != null && Core.scene.getKeyboardFocus() == this){
if(hasKeyboard()){
doInput();
}
});
@@ -238,7 +249,7 @@ public class MapEditorDialog extends Dialog implements Disposable{
editor.renderer.updateAll();
}
private void playtest(){
private void editInGame(){
menu.hide();
ui.loadAnd(() -> {
lastSavedRules = state.rules;
@@ -247,7 +258,9 @@ public class MapEditorDialog extends Dialog implements Disposable{
state.teams = new Teams();
player.reset();
state.rules = Gamemode.editor.apply(lastSavedRules.copy());
state.rules.limitMapArea = false;
state.rules.sector = null;
state.rules.fog = false;
state.map = new Map(StringMap.of(
"name", "Editor Playtesting",
"width", editor.width(),
@@ -255,22 +268,60 @@ public class MapEditorDialog extends Dialog implements Disposable{
));
world.endMapLoad();
player.set(world.width() * tilesize/2f, world.height() * tilesize/2f);
Core.camera.position.set(player);
player.clearUnit();
Groups.unit.clear();
for(var unit : Groups.unit){
if(unit.spawnedByCore){
unit.remove();
}
}
Groups.build.clear();
Groups.weather.clear();
logic.play();
if(player.team().core() == null){
player.set(world.width() * tilesize/2f, world.height() * tilesize/2f);
player.unit(UnitTypes.alpha.spawn(player.team(), player.x, player.y));
var unit = (state.rules.hasEnv(Env.scorching) ? UnitTypes.evoke : UnitTypes.alpha).spawn(player.team(), player.x, player.y);
unit.spawnedByCore = true;
player.unit(unit);
}
player.checkSpawn();
});
}
public void resumeAfterPlaytest(Map map){
beginEditMap(map.file);
}
private void playtest(){
menu.hide();
Map map = save();
if(map != null){
//skip dialog, play immediately when shift clicked
if(Core.input.shift()){
hide();
//auto pick best fit
control.playMap(map, map.applyRules(
Gamemode.survival.valid(map) ? Gamemode.survival :
Gamemode.attack.valid(map) ? Gamemode.attack :
Gamemode.sandbox), true
);
}else{
playtestDialog.playListener = this::hide;
playtestDialog.show(map, true);
}
}
}
public @Nullable Map save(){
boolean isEditor = state.rules.editor;
state.rules.editor = false;
state.rules.objectiveFlags.clear();
state.rules.objectives.each(MapObjective::reset);
String name = editor.tags.get("name", "").trim();
editor.tags.put("rules", JsonIO.write(state.rules));
editor.tags.remove("width");
@@ -278,6 +329,12 @@ public class MapEditorDialog extends Dialog implements Disposable{
player.clearUnit();
//remove player unit
Unit unit = Groups.unit.find(u -> u.spawnedByCore);
if(unit != null){
unit.remove();
}
Map returned = null;
if(name.isEmpty()){
@@ -285,10 +342,19 @@ public class MapEditorDialog extends Dialog implements Disposable{
Core.app.post(() -> ui.showErrorMessage("@editor.save.noname"));
}else{
Map map = maps.all().find(m -> m.name().equals(name));
if(map != null && !map.custom){
if(map != null && !map.custom && !map.workshop){
handleSaveBuiltin(map);
}else{
boolean workshop = false;
//try to preserve Steam ID
if(map != null && map.tags.containsKey("steamid")){
editor.tags.put("steamid", map.tags.get("steamid"));
workshop = true;
}
returned = maps.saveMap(editor.tags);
if(workshop){
returned.workshop = workshop;
}
ui.showInfoFade("@editor.saved");
}
}
@@ -392,7 +458,7 @@ public class MapEditorDialog extends Dialog implements Disposable{
}
public void build(){
float size = 58f;
float size = mobile ? 50f : 58f;
clearChildren();
table(cont -> {
@@ -408,7 +474,7 @@ public class MapEditorDialog extends Dialog implements Disposable{
Cons<EditorTool> addTool = tool -> {
ImageButton button = new ImageButton(ui.getIcon(tool.name()), Styles.clearTogglei);
ImageButton button = new ImageButton(ui.getIcon(tool.name()), Styles.squareTogglei);
button.clicked(() -> {
view.setTool(tool);
if(lastTable[0] != null){
@@ -444,7 +510,7 @@ public class MapEditorDialog extends Dialog implements Disposable{
table.button(b -> {
b.left();
b.marginLeft(6);
b.setStyle(Styles.clearTogglet);
b.setStyle(Styles.flatTogglet);
b.add(Core.bundle.get("toolmode." + name)).left();
b.row();
b.add(Core.bundle.get("toolmode." + name + ".description")).color(Color.lightGray).left();
@@ -484,16 +550,16 @@ public class MapEditorDialog extends Dialog implements Disposable{
tools.defaults().size(size, size);
tools.button(Icon.menu, Styles.cleari, menu::show);
tools.button(Icon.menu, Styles.flati, menu::show);
ImageButton grid = tools.button(Icon.grid, Styles.clearTogglei, () -> view.setGrid(!view.isGrid())).get();
ImageButton grid = tools.button(Icon.grid, Styles.squareTogglei, () -> view.setGrid(!view.isGrid())).get();
addTool.get(EditorTool.zoom);
tools.row();
ImageButton undo = tools.button(Icon.undo, Styles.cleari, editor::undo).get();
ImageButton redo = tools.button(Icon.redo, Styles.cleari, editor::redo).get();
ImageButton undo = tools.button(Icon.undo, Styles.flati, editor::undo).get();
ImageButton redo = tools.button(Icon.redo, Styles.flati, editor::redo).get();
addTool.get(EditorTool.pick);
@@ -515,7 +581,7 @@ public class MapEditorDialog extends Dialog implements Disposable{
addTool.get(EditorTool.fill);
addTool.get(EditorTool.spray);
ImageButton rotate = tools.button(Icon.right, Styles.cleari, () -> editor.rotation = (editor.rotation + 1) % 4).get();
ImageButton rotate = tools.button(Icon.right, Styles.flati, () -> editor.rotation = (editor.rotation + 1) % 4).get();
rotate.getImage().update(() -> {
rotate.getImage().setRotation(editor.rotation * 90);
rotate.getImage().setOrigin(Align.center);
@@ -533,7 +599,7 @@ public class MapEditorDialog extends Dialog implements Disposable{
int i = 0;
for(Team team : Team.baseTeams){
ImageButton button = new ImageButton(Tex.whiteui, Styles.clearTogglePartiali);
ImageButton button = new ImageButton(Tex.whiteui, Styles.clearNoneTogglei);
button.margin(4f);
button.getImageCell().grow();
button.getStyle().imageUpColor = team.color;
@@ -558,27 +624,27 @@ public class MapEditorDialog extends Dialog implements Disposable{
}
}
t.top();
t.add("@editor.brush");
var label = new Label("@editor.brush");
label.setAlignment(Align.center);
label.touchable = Touchable.disabled;
t.top().stack(slider, label).width(size * 3f - 20).padTop(4f);
t.row();
t.add(slider).width(size * 3f - 20).padTop(4f);
}).padTop(5).growX().top();
mid.row();
if(!mobile){
mid.table(t -> {
t.button("@editor.center", Icon.move, Styles.cleart, view::center).growX().margin(9f);
t.button("@editor.center", Icon.move, Styles.flatt, view::center).growX().margin(9f);
}).growX().top();
}
if(experimental){
mid.row();
mid.row();
mid.table(t -> {
t.button("Cliffs", Icon.terrain, Styles.cleart, editor::addCliffs).growX().margin(9f);
}).growX().top();
}
mid.table(t -> {
t.button("@editor.cliffs", Icon.terrain, Styles.flatt, editor::addCliffs).growX().margin(9f);
}).growX().top();
}).margin(0).left().growY();
@@ -627,28 +693,6 @@ public class MapEditorDialog extends Dialog implements Disposable{
editor.undo();
}
//more undocumented features, fantastic
if(Core.input.keyTap(KeyCode.t)){
//clears all 'decoration' from the map
for(int x = 0; x < editor.width(); x++){
for(int y = 0; y < editor.height(); y++){
Tile tile = editor.tile(x, y);
if(tile.block().breakable && tile.block() instanceof Boulder){
tile.setBlock(Blocks.air);
editor.renderer.updatePoint(x, y);
}
if(tile.overlay() != Blocks.air && tile.overlay() != Blocks.spawn){
tile.setOverlay(Blocks.air);
editor.renderer.updatePoint(x, y);
}
}
}
editor.flushOp();
}
if(Core.input.keyTap(KeyCode.y)){
editor.redo();
}
@@ -669,7 +713,7 @@ public class MapEditorDialog extends Dialog implements Disposable{
private void addBlockSelection(Table cont){
blockSelection = new Table();
pane = new ScrollPane(blockSelection);
pane = new ScrollPane(blockSelection, Styles.smallPane);
pane.setFadeScrollBars(false);
pane.setOverscroll(true, false);
pane.exited(() -> {
@@ -680,13 +724,13 @@ public class MapEditorDialog extends Dialog implements Disposable{
cont.table(search -> {
search.image(Icon.zoom).padRight(8);
search.field("", this::rebuildBlockSelection)
search.field("", this::rebuildBlockSelection).growX()
.name("editor/search").maxTextLength(maxNameLength).get().setMessageText("@players.search");
}).pad(-2);
}).growX().pad(-2).padLeft(6f);
cont.row();
cont.table(Tex.underline, extra -> extra.labelWrap(() -> editor.drawBlock.localizedName).width(200f).center()).growX();
cont.row();
cont.add(pane).expandY().top().left();
cont.add(pane).expandY().growX().top().left();
rebuildBlockSelection("");
}
@@ -709,14 +753,14 @@ public class MapEditorDialog extends Dialog implements Disposable{
int i = 0;
for(Block block : blocksOut){
TextureRegion region = block.icon(Cicon.medium);
TextureRegion region = block.uiIcon;
if(!Core.atlas.isFound(region) || !block.inEditor
|| block.buildVisibility == BuildVisibility.debugOnly
|| (!searchText.isEmpty() && !block.localizedName.toLowerCase().contains(searchText.toLowerCase()))
) continue;
ImageButton button = new ImageButton(Tex.whiteui, Styles.clearTogglei);
ImageButton button = new ImageButton(Tex.whiteui, Styles.clearNoneTogglei);
button.getStyle().imageUp = new TextureRegionDrawable(region);
button.clicked(() -> editor.drawBlock = block);
button.resizeImage(8 * 4f);
@@ -725,13 +769,13 @@ public class MapEditorDialog extends Dialog implements Disposable{
if(i == 0) editor.drawBlock = block;
if(++i % 4 == 0){
if(++i % 6 == 0){
blockSelection.row();
}
}
if(i == 0){
blockSelection.add("@none").color(Color.lightGray).padLeft(80f).padTop(10f);
blockSelection.add("@none.found").padLeft(54f).padTop(10f);
}
}
}
+139 -80
View File
@@ -10,11 +10,11 @@ import arc.scene.ui.ImageButton.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import arc.util.async.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.io.*;
import mindustry.maps.*;
import mindustry.maps.filters.*;
import mindustry.maps.filters.GenerateFilter.*;
import mindustry.ui.*;
@@ -22,32 +22,26 @@ import mindustry.ui.dialogs.*;
import mindustry.world.*;
import mindustry.world.blocks.environment.*;
import java.util.concurrent.*;
import static mindustry.Vars.*;
@SuppressWarnings("unchecked")
public class MapGenerateDialog extends BaseDialog{
private final Prov<GenerateFilter>[] filterTypes = new Prov[]{
NoiseFilter::new, ScatterFilter::new, TerrainFilter::new, DistortFilter::new,
RiverNoiseFilter::new, OreFilter::new, OreMedianFilter::new, MedianFilter::new,
BlendFilter::new, MirrorFilter::new, ClearFilter::new, CoreSpawnFilter::new,
EnemySpawnFilter::new, SpawnPathFilter::new
};
private final MapEditor editor;
private final boolean applied;
final boolean applied;
private Pixmap pixmap;
private Texture texture;
private GenerateInput input = new GenerateInput();
Pixmap pixmap;
Texture texture;
GenerateInput input = new GenerateInput();
Seq<GenerateFilter> filters = new Seq<>();
private int scaling = mobile ? 3 : 1;
private Table filterTable;
int scaling = mobile ? 3 : 1;
Table filterTable;
private AsyncExecutor executor = new AsyncExecutor(1);
private AsyncResult<Void> result;
Future<?> result;
boolean generating;
private long[] buffer1, buffer2;
private Cons<Seq<GenerateFilter>> applier;
long[] buffer1, buffer2;
Cons<Seq<GenerateFilter>> applier;
CachedTile ctile = new CachedTile(){
//nothing.
@Override
@@ -62,35 +56,79 @@ public class MapGenerateDialog extends BaseDialog{
};
/** @param applied whether or not to use the applied in-game mode. */
public MapGenerateDialog(MapEditor editor, boolean applied){
public MapGenerateDialog(boolean applied){
super("@editor.generate");
this.editor = editor;
this.applied = applied;
shown(this::setup);
addCloseButton();
addCloseListener();
var style = Styles.flatt;
buttons.defaults().size(180f, 64f).pad(2f);
buttons.button("@back", Icon.left, this::hide);
if(applied){
buttons.button("@editor.apply", Icon.ok, () -> {
ui.loadAnd(() -> {
apply();
hide();
});
}).size(160f, 64f);
}else{
buttons.button("@settings.reset", () -> {
filters.set(maps.readFilters(""));
rebuildFilters();
update();
}).size(160f, 64f);
});
}
buttons.button("@editor.randomize", Icon.refresh, () -> {
for(GenerateFilter filter : filters){
filter.randomize();
}
update();
}).size(160f, 64f);
});
buttons.button("@add", Icon.add, this::showAdd).height(64f).width(150f);
buttons.button("@edit", Icon.edit, () -> {
BaseDialog dialog = new BaseDialog("@editor.export");
dialog.cont.pane(p -> {
p.margin(10f);
p.table(Tex.button, in -> {
in.defaults().size(280f, 60f).left();
in.button("@waves.copy", Icon.copy, style, () -> {
dialog.hide();
Core.app.setClipboardText(JsonIO.write(filters));
}).marginLeft(12f).row();
in.button("@waves.load", Icon.download, style, () -> {
dialog.hide();
try{
filters.set(JsonIO.read(Seq.class, Core.app.getClipboardText()));
rebuildFilters();
update();
}catch(Throwable e){
ui.showException(e);
}
}).marginLeft(12f).disabled(b -> Core.app.getClipboardText() == null).row();
in.button("@clear", Icon.none, style, () -> {
dialog.hide();
filters.clear();
rebuildFilters();
update();
}).marginLeft(12f).row();
if(!applied){
in.button("@settings.reset", Icon.refresh, style, () -> {
dialog.hide();
filters.set(maps.readFilters(""));
rebuildFilters();
update();
}).marginLeft(12f).row();
}
});
});
dialog.addCloseButton();
dialog.show();
});
buttons.button("@add", Icon.add, this::showAdd);
if(!applied){
hidden(this::apply);
@@ -115,13 +153,13 @@ public class MapGenerateDialog extends BaseDialog{
long[] writeTiles = new long[editor.width() * editor.height()];
for(GenerateFilter filter : filters){
input.begin(filter, editor.width(), editor.height(), editor::tile);
input.begin(editor.width(), editor.height(), editor::tile);
//write to buffer
for(int x = 0; x < editor.width(); x++){
for(int y = 0; y < editor.height(); y++){
Tile tile = editor.tile(x, y);
input.apply(x, y, tile.block(), tile.floor(), tile.overlay());
input.set(x, y, tile.block(), tile.floor(), tile.overlay());
filter.apply(input);
writeTiles[x + y*world.width()] = PackTile.get(input.block.id, input.floor.id, input.overlay.id);
}
@@ -173,7 +211,7 @@ public class MapGenerateDialog extends BaseDialog{
@Override
public void draw(){
super.draw();
for(GenerateFilter filter : filters){
for(var filter : filters){
filter.draw(this);
}
}
@@ -194,7 +232,7 @@ public class MapGenerateDialog extends BaseDialog{
}else{
Core.scene.setScrollFocus(null);
}
}).grow().uniformX().get().setScrollingDisabled(true, false);
}).grow().uniformX().scrollX(false);
}).grow();
buffer1 = create();
@@ -214,7 +252,7 @@ public class MapGenerateDialog extends BaseDialog{
filterTable.top().left();
int i = 0;
for(GenerateFilter filter : filters){
for(var filter : filters){
//main container
filterTable.table(Tex.pane, c -> {
@@ -230,30 +268,44 @@ public class MapGenerateDialog extends BaseDialog{
t.add().growX();
ImageButtonStyle style = Styles.geni;
t.defaults().size(42f);
t.defaults().size(42f).padLeft(-5f);
t.button(Icon.refresh, style, () -> {
filter.randomize();
update();
});
}).padLeft(-16f).tooltip("@editor.randomize");
t.button(Icon.upOpen, style, () -> {
int idx = filters.indexOf(filter);
filters.swap(idx, Math.max(0, idx - 1));
if(filter != filters.first()){
t.button(Icon.upOpen, style, () -> {
int idx = filters.indexOf(filter);
filters.swap(idx, Math.max(0, idx - 1));
rebuildFilters();
update();
}).tooltip("@editor.moveup");
}
if(filter != filters.peek()){
t.button(Icon.downOpen, style, () -> {
int idx = filters.indexOf(filter);
filters.swap(idx, Math.min(filters.size - 1, idx + 1));
rebuildFilters();
update();
}).tooltip("@editor.movedown");
}
t.button(Icon.copy, style, () -> {
GenerateFilter copy = filter.copy();
copy.randomize();
filters.insert(filters.indexOf(filter) + 1, copy);
rebuildFilters();
update();
});
t.button(Icon.downOpen, style, () -> {
int idx = filters.indexOf(filter);
filters.swap(idx, Math.min(filters.size - 1, idx + 1));
rebuildFilters();
update();
});
}).tooltip("@editor.copy");
t.button(Icon.cancel, style, () -> {
filters.remove(filter);
rebuildFilters();
update();
});
}).tooltip("@waves.remove");
}).growX();
c.row();
@@ -272,7 +324,6 @@ public class MapGenerateDialog extends BaseDialog{
}).grow().left().pad(6).top();
}).width(280f).pad(3).top().left().fillY();
if(++i % cols == 0){
filterTable.row();
}
@@ -284,30 +335,35 @@ public class MapGenerateDialog extends BaseDialog{
}
void showAdd(){
BaseDialog selection = new BaseDialog("@add");
selection.setFillParent(false);
selection.cont.defaults().size(210f, 60f);
int i = 0;
for(Prov<GenerateFilter> gen : filterTypes){
GenerateFilter filter = gen.get();
var selection = new BaseDialog("@add");
selection.cont.pane(p -> {
p.background(Tex.button);
p.marginRight(14);
p.defaults().size(195f, 56f);
int i = 0;
for(var gen : Maps.allFilterTypes){
var filter = gen.get();
var icon = filter.icon();
if((filter.isPost() && applied)) continue;
if(filter.isPost() && applied) continue;
selection.cont.button(filter.name(), () -> {
filters.add(filter);
p.button((icon == '\0' ? "" : icon + " ") + filter.name(), Styles.flatt, () -> {
filter.randomize();
filters.add(filter);
rebuildFilters();
update();
selection.hide();
}).with(Table::left).get().getLabelCell().growX().left().padLeft(5).labelAlign(Align.left);
if(++i % 3 == 0) p.row();
}
p.button(Iconc.refresh + " " + Core.bundle.get("filter.defaultores"), Styles.flatt, () -> {
maps.addDefaultOres(filters);
rebuildFilters();
update();
selection.hide();
});
if(++i % 2 == 0) selection.cont.row();
}
selection.cont.button("@filter.defaultores", () -> {
maps.addDefaultOres(filters);
rebuildFilters();
update();
selection.hide();
});
}).with(Table::left).get().getLabelCell().growX().left().padLeft(5).labelAlign(Align.left);
}).scrollX(false);
selection.addCloseButton();
selection.show();
@@ -326,7 +382,10 @@ public class MapGenerateDialog extends BaseDialog{
void apply(){
if(result != null){
result.get();
//ignore errors yay
try{
result.get();
}catch(Exception e){}
}
buffer1 = null;
@@ -348,31 +407,31 @@ public class MapGenerateDialog extends BaseDialog{
return;
}
Seq<GenerateFilter> copy = new Seq<>(filters);
var copy = filters.copy();
result = executor.submit(() -> {
result = mainExecutor.submit(() -> {
try{
int w = pixmap.getWidth();
int w = pixmap.width;
world.setGenerating(true);
generating = true;
if(!filters.isEmpty()){
//write to buffer1 for reading
for(int px = 0; px < pixmap.getWidth(); px++){
for(int py = 0; py < pixmap.getHeight(); py++){
for(int px = 0; px < pixmap.width; px++){
for(int py = 0; py < pixmap.height; py++){
buffer1[px + py*w] = pack(editor.tile(px * scaling, py * scaling));
}
}
}
for(GenerateFilter filter : copy){
input.begin(filter, editor.width(), editor.height(), (x, y) -> unpack(buffer1[Mathf.clamp(x / scaling, 0, pixmap.getWidth()-1) + w* Mathf.clamp(y / scaling, 0, pixmap.getHeight()-1)]));
for(var filter : copy){
input.begin(editor.width(), editor.height(), (x, y) -> unpack(buffer1[Mathf.clamp(x / scaling, 0, pixmap.width -1) + w* Mathf.clamp(y / scaling, 0, pixmap.height -1)]));
//read from buffer1 and write to buffer2
pixmap.each((px, py) -> {
int x = px * scaling, y = py * scaling;
long tile = buffer1[px + py * w];
input.apply(x, y, content.block(PackTile.block(tile)), content.block(PackTile.floor(tile)), content.block(PackTile.overlay(tile)));
input.set(x, y, content.block(PackTile.block(tile)), content.block(PackTile.floor(tile)), content.block(PackTile.overlay(tile)));
filter.apply(input);
buffer2[px + py * w] = PackTile.get(input.block.id, input.floor.id, input.overlay.id);
});
@@ -380,8 +439,8 @@ public class MapGenerateDialog extends BaseDialog{
pixmap.each((px, py) -> buffer1[px + py*w] = buffer2[px + py*w]);
}
for(int px = 0; px < pixmap.getWidth(); px++){
for(int py = 0; py < pixmap.getHeight(); py++){
for(int px = 0; px < pixmap.width; px++){
for(int py = 0; py < pixmap.height; py++){
int color;
//get result from buffer1 if there's filters left, otherwise get from editor directly
if(filters.isEmpty()){
@@ -391,7 +450,7 @@ public class MapGenerateDialog extends BaseDialog{
long tile = buffer1[px + py*w];
color = MapIO.colorFor(content.block(PackTile.block(tile)), content.block(PackTile.floor(tile)), content.block(PackTile.overlay(tile)), Team.derelict);
}
pixmap.draw(px, pixmap.getHeight() - 1 - py, color);
pixmap.set(px, pixmap.height - 1 - py, color);
}
}
+70 -33
View File
@@ -1,25 +1,28 @@
package mindustry.editor;
import arc.*;
import arc.scene.ui.*;
import arc.struct.*;
import mindustry.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.io.*;
import mindustry.maps.filters.*;
import mindustry.type.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
public class MapInfoDialog extends BaseDialog{
private final MapEditor editor;
private final WaveInfoDialog waveInfo;
private final MapGenerateDialog generate;
private final CustomRulesDialog ruleInfo = new CustomRulesDialog();
import static mindustry.Vars.*;
public MapInfoDialog(MapEditor editor){
public class MapInfoDialog extends BaseDialog{
private WaveInfoDialog waveInfo = new WaveInfoDialog();
private MapGenerateDialog generate = new MapGenerateDialog(false);
private CustomRulesDialog ruleInfo = new CustomRulesDialog();
private MapObjectivesDialog objectives = new MapObjectivesDialog();
private MapLocalesDialog locales = new MapLocalesDialog();
private MapProcessorsDialog processors = new MapProcessorsDialog();
public MapInfoDialog(){
super("@editor.mapinfo");
this.editor = editor;
this.waveInfo = new WaveInfoDialog(editor);
this.generate = new MapGenerateDialog(editor, false);
addCloseButton();
@@ -37,7 +40,7 @@ public class MapInfoDialog extends BaseDialog{
TextField name = t.field(tags.get("name", ""), text -> {
tags.put("name", text);
}).size(400, 55f).addInputDialog(50).get();
}).size(400, 55f).maxTextLength(50).get();
name.setMessageText("@unknown");
t.row();
@@ -45,38 +48,72 @@ public class MapInfoDialog extends BaseDialog{
TextArea description = t.area(tags.get("description", ""), Styles.areaField, text -> {
tags.put("description", text);
}).size(400f, 140f).addInputDialog(1000).get();
}).size(400f, 140f).maxTextLength(1000).get();
t.row();
t.add("@editor.author").padRight(8).left();
TextField author = t.field(tags.get("author", Core.settings.getString("mapAuthor", "")), text -> {
TextField author = t.field(tags.get("author", ""), text -> {
tags.put("author", text);
Core.settings.put("mapAuthor", text);
}).size(400, 55f).addInputDialog(50).get();
}).size(400, 55f).maxTextLength(50).get();
author.setMessageText("@unknown");
t.row();
t.add("@editor.rules").padRight(8).left();
t.button("@edit", () -> {
ruleInfo.show(Vars.state.rules, () -> Vars.state.rules = new Rules());
hide();
}).left().width(200f);
t.row();
t.add("@editor.waves").padRight(8).left();
t.button("@edit", () -> {
waveInfo.show();
hide();
}).left().width(200f);
t.table(Tex.button, r -> {
r.defaults().width(230f).height(60f);
t.row();
t.add("@editor.generation").padRight(8).left();
t.button("@edit", () -> {
generate.show(Vars.maps.readFilters(editor.tags.get("genfilters", "")),
filters -> editor.tags.put("genfilters", JsonIO.write(filters)));
hide();
}).left().width(200f);
var style = Styles.flatt;
r.button("@editor.rules", Icon.list, style, () -> {
ruleInfo.show(Vars.state.rules, () -> Vars.state.rules = new Rules());
hide();
}).marginLeft(10f);
r.button("@editor.waves", Icon.units, style, () -> {
waveInfo.show();
hide();
}).marginLeft(10f);
r.row();
r.button("@editor.objectives", Icon.info, style, () -> {
objectives.show(state.rules.objectives.all, state.rules.objectives.all::set);
hide();
}).marginLeft(10f);
r.button("@editor.generation", Icon.terrain, style, () -> {
//randomize so they're not all the same seed
var res = maps.readFilters(editor.tags.get("genfilters", ""));
res.each(GenerateFilter::randomize);
generate.show(res,
filters -> {
//reset seed to 0 so it is not written
filters.each(f -> f.seed = 0);
editor.tags.put("genfilters", JsonIO.write(filters));
});
hide();
}).marginLeft(10f);
r.row();
r.button("@editor.locales", Icon.fileText, style, () -> {
try{
MapLocales res = JsonIO.read(MapLocales.class, editor.tags.get("locales", "{}"));
locales.show(res);
}catch(Throwable e){
locales.show(new MapLocales());
ui.showException(e);
}
hide();
}).marginLeft(10f);
r.button("@editor.worldprocessors", Icon.logic, style, () -> {
hide();
processors.show();
}).marginLeft(10f);
}).colspan(2).center();
name.change();
description.change();
+25 -29
View File
@@ -1,9 +1,11 @@
package mindustry.editor;
import arc.*;
import arc.func.*;
import arc.scene.ui.*;
import arc.scene.ui.layout.*;
import arc.util.*;
import mindustry.gen.*;
import mindustry.maps.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
@@ -11,66 +13,60 @@ import mindustry.ui.dialogs.*;
import static mindustry.Vars.*;
public class MapLoadDialog extends BaseDialog{
private Map selected = null;
private @Nullable Map selected = null;
public MapLoadDialog(Cons<Map> loader){
super("@editor.loadmap");
shown(this::rebuild);
hidden(() -> selected = null);
onResize(this::rebuild);
TextButton button = new TextButton("@load");
button.setDisabled(() -> selected == null);
button.clicked(() -> {
buttons.defaults().size(210f, 64f);
buttons.button("@cancel", Icon.cancel, this::hide);
buttons.button("@load", Icon.ok, () -> {
if(selected != null){
loader.get(selected);
hide();
}
});
buttons.defaults().size(200f, 50f);
buttons.button("@cancel", this::hide);
buttons.add(button);
}).disabled(b -> selected == null);
addCloseListener();
makeButtonOverlay();
}
public void rebuild(){
cont.clear();
if(maps.all().size > 0){
selected = maps.all().first();
}
ButtonGroup<TextButton> group = new ButtonGroup<>();
int maxcol = 3;
ButtonGroup<Button> group = new ButtonGroup<>();
int i = 0;
int cols = Math.max((int)(Core.graphics.getWidth() / Scl.scl(300f)), 1);
Table table = new Table();
table.defaults().size(200f, 90f).pad(4f);
table.defaults().size(250f, 90f).pad(4f);
table.margin(10f);
ScrollPane pane = new ScrollPane(table, Styles.horizontalPane);
ScrollPane pane = new ScrollPane(table);
pane.setFadeScrollBars(false);
pane.setScrollingDisabledX(true);
for(Map map : maps.all()){
table.button(b -> {
b.add(new BorderImage(map.safeTexture(), 2f).setScaling(Scaling.fit)).padLeft(5f).size(16 * 4f);
b.add(map.name()).wrap().grow().labelAlign(Align.center).padLeft(5f);
}, Styles.squareTogglet, () -> selected = map).group(group).margin(8f).checked(b -> selected == map);
TextButton button = new TextButton(map.name(), Styles.togglet);
button.add(new BorderImage(map.safeTexture(), 2f).setScaling(Scaling.fit)).size(16 * 4f);
button.getCells().reverse();
button.clicked(() -> selected = map);
button.getLabelCell().grow().left().padLeft(5f);
group.add(button);
table.add(button);
if(++i % maxcol == 0) table.row();
if(++i % cols == 0) table.row();
}
if(maps.all().size == 0){
group.uncheckAll();
if(maps.all().isEmpty()){
table.add("@maps.none").center();
}else{
cont.add("@editor.loadmap");
cont.add("@editor.selectmap");
}
cont.row();
cont.add(pane);
}
}
@@ -0,0 +1,774 @@
package mindustry.editor;
import arc.Core;
import arc.func.*;
import arc.graphics.*;
import arc.scene.style.*;
import arc.scene.ui.*;
import arc.scene.ui.TextButton.*;
import arc.scene.ui.layout.*;
import arc.scene.utils.*;
import arc.struct.*;
import mindustry.*;
import mindustry.ctype.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.io.*;
import mindustry.type.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
import static mindustry.Vars.*;
public class MapLocalesDialog extends BaseDialog{
/** Width of UI property card. */
private static final float cardWidth = 400f;
/** Style for filter options buttons */
private static final TextButtonStyle filterStyle = new TextButtonStyle(){{
up = down = checked = over = Tex.whitePane;
font = Fonts.outline;
fontColor = Color.lightGray;
overFontColor = Pal.accent;
disabledFontColor = Color.gray;
disabled = Styles.black;
}};
/** Icons for use in map locales dialog. */
private static final ContentType[] contentIcons = {ContentType.item, ContentType.block, ContentType.liquid, ContentType.status, ContentType.unit};
private MapLocales locales;
private MapLocales lastSaved;
private boolean saved = true;
private Table langs;
private Table main;
private Table propView;
private String selectedLocale;
private boolean applytoall = true;
private boolean collapsed = false;
private String searchString = "";
private boolean searchByValue = false;
private boolean showCorrect = true;
private boolean showMissing = true;
private boolean showSame = true;
public MapLocalesDialog(){
super("@editor.locales");
selectedLocale = MapLocales.currentLocale();
langs = new Table(Tex.button);
main = new Table();
propView = new Table();
buttons.add("").uniform();
buttons.table(t -> {
t.defaults().pad(3).center();
t.button("@back", Icon.left, () -> {
if(!saved) ui.showConfirm("@editor.locales", "@editor.savechanges", () -> {
editor.tags.put("locales", JsonIO.write(locales));
state.mapLocales = locales;
});
hide();
}).size(210f, 64f);
closeOnBack(() -> {
if(!saved) ui.showConfirm("@editor.locales", "@editor.savechanges", () -> {
editor.tags.put("locales", JsonIO.write(locales));
state.mapLocales = locales;
});
});
t.button("@editor.apply", Icon.ok, () -> {
editor.tags.put("locales", JsonIO.write(locales));
state.mapLocales = locales;
lastSaved = locales.copy();
saved = true;
}).size(210f, 64f).disabled(b -> saved);
t.button("@edit", Icon.edit, this::editDialog).size(210f, 64f);
}).growX();
resized(this::buildMain);
buttons.button("?", () -> ui.showInfo("@locales.info")).size(60f, 64f).uniform();
shown(this::setup);
}
public void show(MapLocales locales){
this.locales = locales;
lastSaved = locales.copy();
saved = true;
show();
}
private void setup(){
cont.clear();
buildTables();
cont.add(langs).left();
cont.table(t -> {
// search/collapse all/filter
t.table(a -> {
a.button(Icon.downOpen, Styles.emptyTogglei, () -> {
collapsed = !collapsed;
buildMain();
}).update(b -> {
b.replaceImage(new Image(collapsed ? Icon.upOpen : Icon.downOpen));
b.setChecked(collapsed);
}).size(35f);
a.button(Icon.filter, Styles.emptyi, () -> filterDialog(this::buildMain)).padLeft(10f).size(35f);
var field = a.field("", v -> {
searchString = v;
buildMain();
}).update(f -> f.setText(searchString)).maxTextLength(64).padLeft(10f).width(250f).update(f -> f.setMessageText(searchByValue ? "@locales.searchvalue": "@locales.searchname")).get();
a.button(Icon.cancel, Styles.emptyi, () -> {
searchString = "";
field.setText("");
buildMain();
}).padLeft(10f).size(35f);
}).row();
t.check("@locales.applytoall", applytoall, b -> applytoall = b).pad(10f).row();
t.add(main).center().grow().row();
}).pad(10f).grow();
// property addition
cont.table(Tex.button, t -> {
TextField name = t.field("name", s -> {}).maxTextLength(64).fillX().padTop(10f).get();
t.row();
TextField value = t.area("text", s -> {}).maxTextLength(1000).fillX().height(140f).get();
t.row();
t.button("@add", Icon.add, () -> {
if(applytoall){
for(var locale : locales.values()){
locale.put(name.getText(), value.getText());
}
}else{
locales.get(selectedLocale).put(name.getText(), value.getText());
}
saved = false;
buildMain();
}).padTop(10f).size(cardWidth, 50f).fillX().row();
}).right();
}
private void buildTables(){
if(!locales.containsKey(selectedLocale)){
locales.put(selectedLocale, new StringMap());
}
buildLocalesTable();
buildMain();
}
private void buildLocalesTable(){
langs.clear();
langs.pane(p -> {
for(var loc : Vars.locales){
String name = loc.toString();
if(locales.containsKey(name)){
p.button(loc.getDisplayName(Core.bundle.getLocale()), Styles.flatTogglet, () -> {
if(name.equals(selectedLocale)) return;
selectedLocale = name;
buildTables();
}).update(b -> b.setChecked(selectedLocale.equals(name))).width(200f).minHeight(50f);
p.button(Icon.edit, Styles.flati, () -> localeEditDialog(name)).size(50f);
p.button(Icon.trash, Styles.flati, () -> ui.showConfirm("@confirm", "@locales.deletelocale", () -> {
locales.remove(name);
selectedLocale = (locales.size != 0 ? locales.keys().next() : Core.settings.getString("locale"));
saved = false;
buildTables();
})).size(50f).row();
}
}
}).row();
langs.button("@add", Icon.add, this::addLocaleDialog).padTop(10f).width(250f);
}
private void buildMain(){
main.clear();
StringMap props = locales.get(selectedLocale);
main.image().color(Pal.gray).height(3f).growX().expandY().top().row();
main.pane(p -> {
int cols = Math.max(1, (int)((Core.graphics.getWidth() / Scl.scl() - 410f) / cardWidth) - 1);
if(props.size == 0){
main.add("@empty").center().row();
return;
}
p.defaults().top();
Table[] colTables = new Table[cols];
for(var i = 0; i < cols; i++){
colTables[i] = new Table();
}
int i = 0;
// To sort properties in alphabetic order
Seq<String> keys = props.keys().toSeq().sort();
for(var key : keys){
var comparsionString = (searchByValue ? props.get(key).toLowerCase() : key.toLowerCase());
if(!searchString.isEmpty() && !comparsionString.contains(searchString.toLowerCase())) continue;
PropertyStatus status = getPropertyStatus(key, props.get(key), selectedLocale, false);
if(status == PropertyStatus.correct && !showCorrect) continue;
if(status == PropertyStatus.missing && !showMissing) continue;
if(status == PropertyStatus.same && !showSame) continue;
colTables[i].table(Tex.whitePane, t -> {
boolean[] shown = {!collapsed};
String[] propKey = {key};
String[] propValue = {props.get(key)};
// collapse button
t.button(Icon.downOpen, Styles.emptyTogglei, () -> shown[0] = !shown[0]).update(b -> {
b.replaceImage(new Image(shown[0] ? Icon.upOpen : Icon.downOpen));
b.setChecked(shown[0]);
}).size(35f);
// property name field
t.field(propKey[0], (f, c) -> c != '=' && c != ':', v -> {
if(props.containsKey(v)){
t.setColor(Color.valueOf("f25555"));
return;
}
if(applytoall){
for(var bundle : locales.values()){
if(!bundle.containsKey(v)){
String value = bundle.get(propKey[0]);
if(value == null) continue;
bundle.remove(propKey[0]);
bundle.put(v, value);
}
}
}else{
if(!props.containsKey(v)){
props.remove(propKey[0]);
props.put(v, propValue[0]);
}
}
propKey[0] = v;
updateCard(t, v, propValue[0]);
saved = false;
}).maxTextLength(64).width(cardWidth - 125f);
// remove button
t.button(Icon.trash, Styles.emptyi, () -> {
if(applytoall){
for(var bundle : locales.values()){
bundle.remove(propKey[0]);
}
}else{
props.remove(propKey[0]);
}
saved = false;
buildMain();
}).size(35f);
// more actions
t.button(Icon.edit, Styles.emptyi, () -> propEditDialog(t, propKey[0], propValue[0])).size(35f).row();
// property value area
t.collapser(c -> c.area(propValue[0], v -> {
props.put(propKey[0], v);
updateCard(t, propKey[0], v);
saved = false;
}).maxTextLength(1000).height(140f).update(a -> {
propValue[0] = props.get(propKey[0]);
a.setText(props.get(propKey[0]));
}).growX(), () -> shown[0]).colspan(4).growX();
updateCard(t, propKey[0], propValue[0]);
}).top().width(cardWidth).pad(5f).row();
i = ++i % cols;
}
if(!colTables[0].hasChildren()){
main.add("@empty").center().row();
}else{
p.add(colTables);
}
}).growX().row();
main.image().color(Pal.gray).height(3f).growX().expandY().bottom().row();
}
private void updateCard(Table table, String propKey, String propValue){
updateCard(table, propKey, propValue, selectedLocale, false);
}
private void updateCard(Table table, String propKey, String propValue, String locale, boolean viewCard){
switch(getPropertyStatus(propKey, propValue, locale, viewCard)){
case missing -> table.setColor(Pal.accent);
case same -> table.setColor(Pal.techBlue);
case correct -> table.setColor(Pal.gray);
}
}
// Property statuses for main dialog and property view dialog are a bit different
private PropertyStatus getPropertyStatus(String propKey, String propValue, String locale, boolean forView){
if(forView && propValue == null) return PropertyStatus.missing;
for(var bundle : locales.entries()){
if(!forView && bundle.key.equals(selectedLocale)) continue;
if(forView && bundle.key.equals(locale)) continue;
StringMap props = bundle.value;
if(!props.containsKey(propKey)){
if(!forView) return PropertyStatus.missing;
}else{
if(props.get(propKey).equals(propValue)){
return PropertyStatus.same;
}
}
}
return PropertyStatus.correct;
}
private void addLocaleDialog(){
BaseDialog dialog = new BaseDialog("@add");
dialog.cont.pane(t -> {
for(var loc : Vars.locales){
String name = loc.toString();
if(!locales.containsKey(name)){
t.button(loc.getDisplayName(Core.bundle.getLocale()), Styles.flatTogglet, () -> {
if(name.equals(selectedLocale)) return;
locales.put(name, new StringMap());
selectedLocale = name;
saved = false;
buildTables();
dialog.hide();
}).update(b -> b.setChecked(selectedLocale.equals(name))).size(400f, 50f).row();
}
}
});
dialog.addCloseButton();
dialog.show();
}
private void propEditDialog(Table card, String key, String value){
BaseDialog dialog = new BaseDialog("@edit");
dialog.cont.pane(p -> {
p.margin(10f);
p.table(Tex.button, t -> {
t.defaults().size(450f, 60f).left();
t.button("@locales.addtoother", Icon.add, Styles.flatt, () -> {
for(var bundle : locales.values()){
if(!bundle.containsKey(key)){
bundle.put(key, value);
}
}
saved = false;
updateCard(card, key, value);
dialog.hide();
}).marginLeft(12f).row();
t.button("@locales.viewproperty", Icon.zoom, Styles.flatt, () -> {
viewPropertyDialog(key);
dialog.hide();
}).marginLeft(12f).row();
t.button("@locales.addicon", Icon.image, Styles.flatt, () -> {
addIconDialog(res -> {
locales.get(selectedLocale).put(key, value + res);
saved = false;
});
dialog.hide();
}).marginLeft(12f).row();
t.button("@locales.rollback", Icon.undo, Styles.flatt, () -> {
locales.get(selectedLocale).put(key, lastSaved.get(selectedLocale).get(key));
buildTables();
dialog.hide();
}).disabled(b -> {
if(!lastSaved.containsKey(selectedLocale)) return true;
StringMap savedMap = lastSaved.get(selectedLocale);
return !savedMap.containsKey(key) || savedMap.get(key).equals(locales.get(selectedLocale).get(key));
}).marginLeft(12f).row();
});
});
dialog.addCloseButton();
dialog.show();
}
private void localeEditDialog(String locale){
BaseDialog dialog = new BaseDialog("@edit");
dialog.cont.pane(p -> {
p.margin(10f);
p.table(Tex.button, t -> {
t.defaults().size(350f, 60f).left();
t.button("@waves.copy", Icon.copy, Styles.flatt, () -> {
Core.app.setClipboardText(writeLocale(locale));
ui.showInfoFade("@copied");
dialog.hide();
}).marginLeft(12f).row();
t.button("@waves.load", Icon.download, Styles.flatt, () -> {
locales.put(locale, readLocale(Core.app.getClipboardText()));
buildTables();
saved = false;
dialog.hide();
}).disabled(Core.app.getClipboardText() == null).marginLeft(12f).row();
});
});
dialog.addCloseButton();
dialog.show();
}
private void editDialog(){
BaseDialog dialog = new BaseDialog("@edit");
dialog.cont.pane(p -> {
p.margin(10f);
p.table(Tex.button, t -> {
t.defaults().size(450f, 60f).left();
t.button("@waves.copy", Icon.copy, Styles.flatt, () -> {
Core.app.setClipboardText(writeBundles());
ui.showInfoFade("@copied");
dialog.hide();
}).marginLeft(12f).row();
t.button("@waves.load", Icon.download, Styles.flatt, () -> {
locales = readBundles(Core.app.getClipboardText());
buildTables();
saved = false;
dialog.hide();
}).disabled(Core.app.getClipboardText() == null).marginLeft(12f).row();
t.button("@locales.rollback", Icon.undo, Styles.flatt, () -> {
locales = lastSaved.copy();
saved = true;
buildTables();
dialog.hide();
}).disabled(b -> saved).marginLeft(12f).row();
});
});
dialog.addCloseButton();
dialog.show();
}
private void viewPropertyDialog(String key){
BaseDialog dialog = new BaseDialog(Core.bundle.format("locales.viewing", key));
dialog.cont.table(t -> {
t.button(Icon.filter, Styles.emptyi, () -> filterDialog(() -> buildPropView(key))).size(35f);
var field = t.field(searchString, v -> {
searchString = v;
buildPropView(key);
}).update(f -> f.setText(searchString)).maxTextLength(64).padLeft(10f).width(250f).update(f -> f.setMessageText(searchByValue ? "@locales.searchvalue" : "@locales.searchlocale")).get();
t.button(Icon.cancel, Styles.emptyi, () -> {
searchString = "";
field.setText("");
buildPropView(key);
}).padLeft(10f).size(35f);
}).row();
buildPropView(key);
dialog.cont.add(propView).grow().center().row();
dialog.addCloseButton();
dialog.closeOnBack();
dialog.hidden(this::buildMain);
dialog.show();
}
private void buildPropView(String key){
propView.clear();
propView.image().color(Pal.gray).height(3f).fillX().top().row();
propView.pane(p -> {
int cols = Math.max(1, (int)((Core.graphics.getWidth() / Scl.scl() - 100f) / cardWidth));
if(cols == 0){
propView.add("@empty").center().row();
return;
}
p.defaults().top();
Table[] colTables = new Table[cols];
for(var i = 0; i < cols; i++){
colTables[i] = new Table();
}
int i = 0;
for(var loc : Vars.locales){
String name = loc.toString();
if(!locales.containsKey(name)) continue;
PropertyStatus status = getPropertyStatus(key, locales.get(name).get(key), name, true);
if(status == PropertyStatus.correct && !showCorrect) continue;
if(status == PropertyStatus.missing && !showMissing) continue;
if(status == PropertyStatus.same && !showSame) continue;
if(status != PropertyStatus.missing){
var comparsionString = (searchByValue ? locales.get(name).get(key).toLowerCase() : loc.getDisplayName(Core.bundle.getLocale()).toLowerCase());
if(!searchString.isEmpty() && !comparsionString.contains(searchString.toLowerCase())) continue;
}
colTables[i].table(Tex.whitePane, t -> {
t.add(loc.getDisplayName(Core.bundle.getLocale())).left().color(Pal.accent).row();
t.image().color(Pal.accent).fillX().row();
if(status == PropertyStatus.missing){
t.table(b ->
b.button("@add", Icon.add, () -> {
locales.get(name).put(key, "moai");
t.getCells().get(2).clearElement();
t.getCells().remove(2);
t.area(locales.get(name).get(key), v -> {
locales.get(name).put(key, v);
saved = false;
}).maxTextLength(1000).height(140f).growX().row();
}).size(160f, 50f)).height(140f).growX().row();
}else{
t.area(locales.get(name).get(key), v -> {
locales.get(name).put(key, v);
saved = false;
}).maxTextLength(1000).height(140f).growX().row();
}
}).update(t -> updateCard(t, key, locales.get(name).get(key), name, true)).top().width(cardWidth).pad(5f).row();
i = ++i % cols;
}
if(!colTables[0].hasChildren()){
propView.add("@empty").center().row();
}else{
p.add(colTables);
}
}).grow().row();
propView.image().color(Pal.gray).height(3f).fillX().bottom().row();
}
private void filterDialog(Runnable hidden){
BaseDialog dialog = new BaseDialog("@locales.filter");
dialog.cont.table(t -> {
t.add("@search").row();
t.table(b -> {
b.button("@locales.byname", Styles.togglet, () -> searchByValue = false).size(300f, 50f).checked(v -> !searchByValue);
b.button("@locales.byvalue", Styles.togglet, () -> searchByValue = true).padLeft(10f).size(300f, 50f).checked(v -> searchByValue);
}).padTop(5f);
}).row();
dialog.cont.button("@locales.showcorrect", Icon.ok, filterStyle, () -> showCorrect = !showCorrect).update(b -> {
((Image)b.getChildren().get(1)).setDrawable(showCorrect ? Icon.ok : Icon.cancel);
b.setChecked(showCorrect);
}).size(450f, 100f).color(Pal.gray).padTop(65f);
dialog.cont.row();
dialog.cont.button("@locales.showmissing", Icon.ok, filterStyle, () -> showMissing = !showMissing).update(b -> {
((Image)b.getChildren().get(1)).setDrawable(showMissing ? Icon.ok : Icon.cancel);
b.setChecked(showMissing);
}).size(450f, 100f).color(Pal.accent).padTop(65f);
dialog.cont.row();
dialog.cont.button("@locales.showsame", Icon.ok, filterStyle, () -> showSame = !showSame).update(b -> {
((Image)b.getChildren().get(1)).setDrawable(showSame ? Icon.ok : Icon.cancel);
b.setChecked(showSame);
}).size(450f, 100f).color(Pal.techBlue).padTop(65f);
dialog.buttons.button("@back", Icon.left, () -> {
hidden.run();
dialog.hide();
}).size(210f, 64f);
dialog.closeOnBack(hidden);
dialog.show();
}
private void addIconDialog(Cons<String> cons){
BaseDialog dialog = new BaseDialog("@locales.addicon");
Table icons = new Table();
TextField search = Elem.newField("", v -> iconsTable(icons, v.replace(" ", "").toLowerCase(), dialog, cons));
search.setMessageText("@search");
dialog.cont.table(t -> {
t.add(search).maxTextLength(64).padLeft(10f).width(250f);
t.button(Icon.cancel, Styles.emptyi, () -> {
search.setText("");
iconsTable(icons, "", dialog, cons);
}).padLeft(10f).size(35f);
}).row();
dialog.cont.pane(icons).scrollX(false);
dialog.resized(true, () -> iconsTable(icons, search.getText().replace(" ", "").toLowerCase(), dialog, cons));
dialog.addCloseButton();
dialog.closeOnBack();
dialog.setFillParent(true);
dialog.show();
}
private void iconsTable(Table table, String search, Dialog dialog, Cons<String> cons){
table.clear();
table.marginRight(19f).marginLeft(12f);
table.defaults().size(48f);
int cols = (int)Math.min(20, Core.graphics.getWidth() / Scl.scl(52f));
int i = 0;
var codes = new ObjectIntMap<>(Iconc.codes);
for(var name : codes.keys()){
if(!name.toLowerCase().contains(search)) codes.remove(name);
}
if(codes.size > 0) table.image().colspan(cols).growX().width(-1f).height(3f).color(Pal.accent).row();
for(var icon : codes){
String res = (char)icon.value + "";
table.button(Icon.icons.get(icon.key), Styles.flati, iconMed, () -> {
cons.get(res);
dialog.hide();
}).tooltip(icon.key);
if(++i % cols == 0) table.row();
}
for(ContentType ctype : contentIcons){
var all = content.getBy(ctype).<UnlockableContent>as().select(u -> u.localizedName.replace(" ", "").toLowerCase().contains(search) && u.uiIcon.found());
table.row();
if(all.size > 0) table.image().colspan(cols).growX().width(-1f).height(3f).color(Pal.accent).row();
i = 0;
for(UnlockableContent u : all){
table.button(new TextureRegionDrawable(u.uiIcon), Styles.flati, iconMed, () -> {
cons.get(u.emoji() + "");
dialog.hide();
}).tooltip(u.localizedName);
if(++i % cols == 0) table.row();
}
}
var teams = new Seq<>(Team.baseTeams);
teams = teams.select(u -> u.localized().toLowerCase().contains(search) && Core.atlas.has("team-" + u.name));
table.row();
if(teams.size > 0) table.image().colspan(cols).growX().width(-1f).height(3f).color(Pal.accent).row();
for(Team team : teams){
var region = Core.atlas.find("team-" + team.name);
table.button(new TextureRegionDrawable(region), Styles.flati, iconMed, () -> {
cons.get(team.emoji);
dialog.hide();
}).tooltip(team.localized());
if(++i % cols == 0) table.row();
}
}
private String writeBundles(){
StringBuilder data = new StringBuilder();
for(var locale : locales.keys()){
data.append(locale).append(":\n").append(writeLocale(locale));
}
return data.toString();
}
private String writeLocale(String key){
StringBuilder data = new StringBuilder();
if(!locales.containsKey(key)) return "";
for(var prop : locales.get(key).entries()){
// Convert \n in plain text to \\n, then convert newlines to \n
data.append(prop.key).append(" = ").append(prop.value
.replace("\\n", "\\\\n").replace("\n", "\\n")).append("\n");
}
return data.toString();
}
private MapLocales readBundles(String data){
MapLocales bundles = new MapLocales();
String currentLocale = "";
for(var line : data.split("\\r?\\n|\\r")){
if(line.endsWith(":") && !line.contains("=")){
currentLocale = line.substring(0, line.length() - 1);
bundles.put(currentLocale, new StringMap());
}else{
int sepIndex = line.indexOf(" = ");
if(sepIndex != -1 && !currentLocale.isEmpty()){
// Convert \n in file to newlines in text, then revert newlines with escape characters
bundles.get(currentLocale).put(line.substring(0, sepIndex), line.substring(sepIndex + 3)
.replace("\\n", "\n").replace("\\\n", "\\n"));
}
}
}
return bundles;
}
private StringMap readLocale(String data){
StringMap map = new StringMap();
for(var line : data.split("\\r?\\n|\\r")){
int sepIndex = line.indexOf(" = ");
if(sepIndex != -1){
// Convert \n in file to newlines in text, then revert newlines with escape characters
map.put(line.substring(0, sepIndex), line.substring(sepIndex + 3)
.replace("\\n", "\n").replace("\\\n", "\\n"));
}
}
return map;
}
private enum PropertyStatus{
correct,
missing,
same
}
}
@@ -0,0 +1,557 @@
package mindustry.editor;
import arc.*;
import arc.graphics.g2d.*;
import arc.input.*;
import arc.math.*;
import arc.math.geom.*;
import arc.scene.event.*;
import arc.scene.ui.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import mindustry.editor.MapObjectivesCanvas.ObjectiveTilemap.ObjectiveTile.*;
import mindustry.editor.MapObjectivesDialog.*;
import mindustry.game.MapObjectives.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
import static mindustry.Vars.*;
@SuppressWarnings("unchecked")
public class MapObjectivesCanvas extends WidgetGroup{
public static final int
objWidth = 5, objHeight = 2,
bounds = 100;
public final float unitSize = Scl.scl(48f);
public Seq<MapObjective> objectives = new Seq<>();
public ObjectiveTilemap tilemap;
protected MapObjective query;
private boolean pressed;
private long visualPressed;
private int queryX = -objWidth, queryY = -objHeight;
public MapObjectivesCanvas(){
setFillParent(true);
addChild(tilemap = new ObjectiveTilemap());
addCaptureListener(new InputListener(){
@Override
public boolean touchDown(InputEvent event, float x, float y, int pointer, KeyCode button){
if(query != null && button == KeyCode.mouseRight){
stopQuery();
event.stop();
return true;
}else{
return false;
}
}
});
addCaptureListener(new ElementGestureListener(){
int pressPointer = -1;
@Override
public void pan(InputEvent event, float x, float y, float deltaX, float deltaY){
if(tilemap.moving != null || tilemap.connecting != null) return;
tilemap.x = Mathf.clamp(tilemap.x + deltaX, -bounds * unitSize + width, bounds * unitSize);
tilemap.y = Mathf.clamp(tilemap.y + deltaY, -bounds * unitSize + height, bounds * unitSize);
}
@Override
public void tap(InputEvent event, float x, float y, int count, KeyCode button){
if(query == null) return;
Vec2 pos = localToDescendantCoordinates(tilemap, Tmp.v1.set(x, y));
queryX = Mathf.round((pos.x - objWidth * unitSize / 2f) / unitSize);
queryY = Mathf.floor((pos.y - unitSize) / unitSize);
// In mobile, placing the query is done in a separate button.
if(!mobile) placeQuery();
}
@Override
public void touchDown(InputEvent event, float x, float y, int pointer, KeyCode button){
if(pressPointer != -1) return;
pressPointer = pointer;
pressed = true;
visualPressed = Time.millis() + 100;
}
@Override
public void touchUp(InputEvent event, float x, float y, int pointer, KeyCode button){
if(pointer == pressPointer){
pressPointer = -1;
pressed = false;
}
}
});
}
public void clearObjectives(){
stopQuery();
tilemap.clearTiles();
tilemap.x = 0f;
tilemap.y = 0f;
}
protected void stopQuery(){
if(query == null) return;
query = null;
Core.graphics.restoreCursor();
}
public void query(MapObjective obj){
stopQuery();
query = obj;
}
public void placeQuery(){
if(isQuerying() && tilemap.createTile(queryX, queryY, query)){
objectives.add(query);
stopQuery();
}
}
public boolean isQuerying(){
return query != null;
}
public boolean isVisualPressed(){
return pressed || visualPressed > Time.millis();
}
public class ObjectiveTilemap extends WidgetGroup{
/** The connector button that is being pressed. */
protected @Nullable Connector connecting;
/** The current tile that is being moved. */
protected @Nullable ObjectiveTile moving;
public ObjectiveTilemap(){
setTransform(false);
setSize(getPrefWidth(), getPrefHeight());
touchable(() -> isQuerying() ? Touchable.disabled : Touchable.childrenOnly);
}
@Override
public void draw(){
validate();
int minX = Math.max(Mathf.floor((x - width - 1f) / unitSize), -bounds), minY = Math.max(Mathf.floor((y - height - 1f) / unitSize), -bounds),
maxX = Math.min(Mathf.ceil((x + width + 1f) / unitSize), bounds), maxY = Math.min(Mathf.ceil((y + height + 1f) / unitSize), bounds);
float progX = x % unitSize, progY = y % unitSize;
Lines.stroke(3f);
Draw.color(Pal.darkestGray, parentAlpha);
for(int x = minX; x <= maxX; x++) Lines.line(progX + x * unitSize, minY * unitSize, progX + x * unitSize, maxY * unitSize);
for(int y = minY; y <= maxY; y++) Lines.line(minX * unitSize, progY + y * unitSize, maxX * unitSize, progY + y * unitSize);
if(isQuerying()){
int tx, ty;
if(mobile){
tx = queryX;
ty = queryY;
}else{
Vec2 pos = screenToLocalCoordinates(Core.input.mouse());
tx = Mathf.round((pos.x - objWidth * unitSize / 2f) / unitSize);
ty = Mathf.floor((pos.y - unitSize) / unitSize);
}
Lines.stroke(4f);
Draw.color(
isVisualPressed() ? Pal.metalGrayDark : validPlace(tx, ty, null) ? Pal.accent : Pal.remove,
parentAlpha
);
Lines.rect(x + tx * unitSize, y + ty * unitSize, objWidth * unitSize, objHeight * unitSize);
}
if(moving != null){
int tx, ty;
float x = this.x + (tx = Mathf.round(moving.x / unitSize)) * unitSize;
float y = this.y + (ty = Mathf.round(moving.y / unitSize)) * unitSize;
Draw.color(
validPlace(tx, ty, moving) ? Pal.accent : Pal.remove,
0.5f * parentAlpha
);
Fill.crect(x, y, objWidth * unitSize, objHeight * unitSize);
}
Draw.reset();
super.draw();
Draw.reset();
Seq<ObjectiveTile> tiles = getChildren().as();
Connector conTarget = null;
if(connecting != null){
Vec2 pos = connecting.localToAscendantCoordinates(this, Tmp.v1.set(connecting.pointX, connecting.pointY));
if(hit(pos.x, pos.y, true) instanceof Connector con && connecting.canConnectTo(con)) conTarget = con;
}
boolean removing = false;
for(var tile : tiles){
for(var parent : tile.obj.parents){
var parentTile = tiles.find(t -> t.obj == parent);
if(parentTile == null) continue;
Connector
conFrom = parentTile.conChildren,
conTo = tile.conParent;
if(conTarget != null && (
(connecting.findParent && connecting == conTo && conTarget == conFrom) ||
(!connecting.findParent && connecting == conFrom && conTarget == conTo)
)){
removing = true;
continue;
}
Vec2
from = conFrom.localToAscendantCoordinates(this, Tmp.v1.set(conFrom.getWidth() / 2f, conFrom.getHeight() / 2f)).add(x, y),
to = conTo.localToAscendantCoordinates(this, Tmp.v2.set(conTo.getWidth() / 2f, conTo.getHeight() / 2f)).add(x, y);
drawCurve(false, from.x, from.y, to.x, to.y);
}
}
if(connecting != null){
Vec2
mouse = (conTarget == null
? connecting.localToAscendantCoordinates(this, Tmp.v1.set(connecting.pointX, connecting.pointY))
: conTarget.localToAscendantCoordinates(this, Tmp.v1.set(conTarget.getWidth() / 2f, conTarget.getHeight() / 2f))
).add(x, y),
anchor = connecting.localToAscendantCoordinates(this, Tmp.v2.set(connecting.getWidth() / 2f, connecting.getHeight() / 2f)).add(x, y);
Vec2
from = connecting.findParent ? mouse : anchor,
to = connecting.findParent ? anchor : mouse;
drawCurve(removing, from.x, from.y, to.x, to.y);
}
Draw.reset();
}
protected void drawCurve(boolean remove, float x1, float y1, float x2, float y2){
Lines.stroke(4f);
Draw.color(remove ? Pal.remove : Pal.accent, parentAlpha);
Fill.square(x1, y1, 8f, 45f);
Fill.square(x2, y2, 8f, 45f);
float dist = Math.abs(x1 - x2) / 2f;
float cx1 = x1 + dist;
float cx2 = x2 - dist;
Lines.curve(x1, y1, cx1, y1, cx2, y2, x2, y2, Math.max(4, (int) (Mathf.dst(x1, y1, x2, y2) / 4f)));
float progress = (Time.time % (60 * 4)) / (60 * 4);
float t2 = progress * progress;
float t3 = progress * t2;
float t1 = 1 - progress;
float t13 = t1 * t1 * t1;
float kx1 = t13 * x1 + 3 * progress * t1 * t1 * cx1 + 3 * t2 * t1 * cx2 + t3 * x2;
float ky1 = t13 *y1 + 3 * progress * t1 * t1 * y1 + 3 * t2 * t1 * y2 + t3 * y2;
Fill.circle(kx1, ky1, 6f);
Draw.reset();
}
public boolean validPlace(int x, int y, @Nullable ObjectiveTile ignore){
Tmp.r1.set(x, y, objWidth, objHeight).grow(-0.001f);
if(!Tmp.r2.setCentered(0, 0, bounds * 2, bounds * 2).contains(Tmp.r1)){
return false;
}
for(var other : children){
if(other instanceof ObjectiveTile tile && tile != ignore && Tmp.r2.set(tile.tx, tile.ty, objWidth, objHeight).overlaps(Tmp.r1)){
return false;
}
}
return true;
}
public boolean createTile(MapObjective obj){
return createTile(obj.editorX, obj.editorY, obj);
}
public boolean createTile(int x, int y, MapObjective obj){
if(!validPlace(x, y, null)) return false;
ObjectiveTile tile = new ObjectiveTile(obj, x, y);
tile.pack();
addChild(tile);
return true;
}
public boolean moveTile(ObjectiveTile tile, int newX, int newY){
if(!validPlace(newX, newY, tile)) return false;
tile.pos(newX, newY);
return true;
}
public void removeTile(ObjectiveTile tile){
if(!tile.isDescendantOf(this)) return;
tile.remove();
}
public void clearTiles(){
clearChildren();
}
@Override
public float getPrefWidth(){
return bounds * unitSize;
}
@Override
public float getPrefHeight(){
return bounds * unitSize;
}
public class ObjectiveTile extends Table{
public final MapObjective obj;
public int tx, ty;
public final Mover mover;
public final Connector conParent, conChildren;
public ObjectiveTile(MapObjective obj, int x, int y){
this.obj = obj;
setTransform(false);
setClip(false);
add(conParent = new Connector(true)).size(unitSize / Scl.scl(1f), unitSize * 2 / Scl.scl(1f));
table(Tex.whiteui, t -> {
float pad = (unitSize / Scl.scl(1f) - 32f) / 2f - 4f;
t.margin(pad);
t.touchable(() -> Touchable.enabled);
t.setColor(Pal.gray);
t.labelWrap(obj.typeName())
.style(Styles.outlineLabel)
.left().grow().get()
.setAlignment(Align.left);
t.row();
t.table(b -> {
b.left().defaults().size(40f);
b.button(Icon.pencilSmall, () -> {
BaseDialog dialog = new BaseDialog("@editor.objectives");
dialog.cont.pane(Styles.noBarPane, list -> list.top().table(e -> {
e.margin(0f);
MapObjectivesDialog.getInterpreter((Class<MapObjective>)obj.getClass()).build(
e, obj.typeName(), new TypeInfo(obj.getClass()),
null, null, null,
() -> obj,
res -> {}
);
}).width(Math.min(Core.graphics.getWidth() * 0.95f / Scl.scl(1f) - Scl.scl(20f), 700f)).fillY()).grow();
dialog.addCloseButton();
dialog.show();
});
b.button(Icon.trashSmall, () -> removeTile(this));
}).left().grow();
}).growX().height(unitSize / Scl.scl(1f) * 2).get().addCaptureListener(mover = new Mover());
add(conChildren = new Connector(false)).size(unitSize / Scl.scl(1f), unitSize / Scl.scl(1f) * 2);
setSize(getPrefWidth(), getPrefHeight());
pos(x, y);
}
public void pos(int x, int y){
tx = obj.editorX = x;
ty = obj.editorY = y;
this.x = x * unitSize;
this.y = y * unitSize;
}
@Override
public float getPrefWidth(){
return objWidth * unitSize;
}
@Override
public float getPrefHeight(){
return objHeight * unitSize;
}
@Override
public boolean remove(){
if(super.remove()){
obj.parents.clear();
var it = objectives.iterator();
while(it.hasNext()){
var next = it.next();
if(next == obj){
it.remove();
}else{
next.parents.remove(obj);
}
}
return true;
}else{
return false;
}
}
public class Mover extends InputListener{
public int prevX, prevY;
public float lastX, lastY;
@Override
public boolean touchDown(InputEvent event, float x, float y, int pointer, KeyCode button){
if(moving != null) return false;
moving = ObjectiveTile.this;
moving.toFront();
prevX = moving.tx;
prevY = moving.ty;
// Convert to world pos first because the button gets dragged too.
Vec2 pos = event.listenerActor.localToStageCoordinates(Tmp.v1.set(x, y));
lastX = pos.x;
lastY = pos.y;
return true;
}
@Override
public void touchDragged(InputEvent event, float x, float y, int pointer){
Vec2 pos = event.listenerActor.localToStageCoordinates(Tmp.v1.set(x, y));
moving.moveBy(pos.x - lastX, pos.y - lastY);
lastX = pos.x;
lastY = pos.y;
}
@Override
public void touchUp(InputEvent event, float x, float y, int pointer, KeyCode button){
if(!moveTile(moving,
Mathf.round(moving.x / unitSize),
Mathf.round(moving.y / unitSize)
)) moving.pos(prevX, prevY);
moving = null;
}
}
public class Connector extends Button{
public float pointX, pointY;
public final boolean findParent;
public Connector(boolean findParent){
super(new ButtonStyle(){{
down = findParent ? Tex.buttonSideLeftDown : Tex.buttonSideRightDown;
up = findParent ? Tex.buttonSideLeft : Tex.buttonSideRight;
over = findParent ? Tex.buttonSideLeftOver : Tex.buttonSideRightOver;
}});
this.findParent = findParent;
clearChildren();
addCaptureListener(new InputListener(){
int conPointer = -1;
@Override
public boolean touchDown(InputEvent event, float x, float y, int pointer, KeyCode button){
if(conPointer != -1) return false;
conPointer = pointer;
if(connecting != null) return false;
connecting = Connector.this;
pointX = x;
pointY = y;
return true;
}
@Override
public void touchDragged(InputEvent event, float x, float y, int pointer){
if(conPointer != pointer) return;
pointX = x;
pointY = y;
}
@Override
public void touchUp(InputEvent event, float x, float y, int pointer, KeyCode button){
if(conPointer != pointer || connecting != Connector.this) return;
conPointer = -1;
Vec2 pos = Connector.this.localToAscendantCoordinates(ObjectiveTilemap.this, Tmp.v1.set(x, y));
if(ObjectiveTilemap.this.hit(pos.x, pos.y, true) instanceof Connector con && con.canConnectTo(Connector.this)){
if(findParent){
if(!obj.parents.remove(con.tile().obj)) obj.parents.add(con.tile().obj);
}else{
if(!con.tile().obj.parents.remove(obj)) con.tile().obj.parents.add(obj);
}
}
connecting = null;
}
});
}
public boolean canConnectTo(Connector other){
return
findParent != other.findParent &&
tile() != other.tile();
}
@Override
public void draw(){
super.draw();
float cx = x + width / 2f;
float cy = y + height / 2f;
// these are all magic numbers tweaked until they looked good in-game, don't mind them.
Lines.stroke(3f, Pal.accent);
if(findParent){
Lines.line(cx, cy + 9f, cx + 9f, cy);
Lines.line(cx + 9f, cy, cx, cy - 9f);
}else{
Lines.square(cx, cy, 9f, 45f);
}
}
public ObjectiveTile tile(){
return ObjectiveTile.this;
}
@Override
public boolean isPressed(){
return super.isPressed() || connecting == this;
}
@Override
public boolean isOver(){
return super.isOver() && (connecting == null || connecting.canConnectTo(this));
}
}
}
}
}
@@ -0,0 +1,756 @@
package mindustry.editor;
import arc.*;
import arc.func.*;
import arc.graphics.*;
import arc.math.geom.*;
import arc.scene.event.*;
import arc.scene.ui.*;
import arc.scene.ui.TextField.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import mindustry.content.*;
import mindustry.ctype.*;
import mindustry.game.*;
import mindustry.game.MapObjectives.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.io.*;
import mindustry.type.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
import mindustry.world.*;
import java.lang.annotation.*;
import java.lang.reflect.*;
import static mindustry.Vars.*;
import static mindustry.editor.MapObjectivesCanvas.*;
@SuppressWarnings({"unchecked", "rawtypes"})
public class MapObjectivesDialog extends BaseDialog{
public MapObjectivesCanvas canvas;
protected Cons<Seq<MapObjective>> out = arr -> {};
/** Defines default value providers. */
private static final ObjectMap<Class<?>, FieldProvider<?>> providers = new ObjectMap<>();
/** Maps annotation type with its field parsers. Non-annotated fields are mapped with {@link Override}. */
private static final ObjectMap<Class<? extends Annotation>, ObjectMap<Class<?>, FieldInterpreter<?>>> interpreters = new ObjectMap<>();
static{
// Default un-annotated field interpreters.
setProvider(String.class, (type, cons) -> cons.get(""));
setInterpreter(String.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
if(field != null && field.isAnnotationPresent(Multiline.class)){
cont.area(get.get(), set).height(100f).growX();
}else{
cont.field(get.get(), set).growX();
}
});
setProvider(boolean.class, (type, cons) -> cons.get(false));
setInterpreter(boolean.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
cont.check("", get.get(), set::get).growX().fillY().get().getLabelCell().growX();
});
setProvider(byte.class, (type, cons) -> cons.get((byte)0));
setInterpreter(byte.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
cont.field(Byte.toString(get.get()), str -> set.get((byte)Strings.parseInt(str)))
.growX().fillY()
.valid(Strings::canParseInt)
.get().setFilter(TextFieldFilter.digitsOnly);
});
setProvider(int.class, (type, cons) -> cons.get(0));
setInterpreter(int.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
cont.field(Integer.toString(get.get()), str -> set.get(Strings.parseInt(str)))
.growX().fillY()
.valid(Strings::canParseInt)
.get().setFilter(TextFieldFilter.digitsOnly);
});
setProvider(float.class, (type, cons) -> cons.get(0f));
setInterpreter(float.class, (cont, name, type, field, remover, indexer, get, set) -> {
float m = 1f;
if(field != null){
if(field.isAnnotationPresent(Second.class)){
m = 60f;
}else if(field.isAnnotationPresent(TilePos.class)){
m = 8f;
}
}
float mult = m;
name(cont, name, remover, indexer);
cont.field(Float.toString(get.get() / mult), str -> set.get(Strings.parseFloat(str) * mult))
.growX().fillY()
.valid(Strings::canParseFloat)
.get().setFilter(TextFieldFilter.floatsOnly);
});
setProvider(UnlockableContent.class, (type, cons) -> cons.get(Blocks.coreShard));
setInterpreter(UnlockableContent.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
cont.table(t -> t.left().button(
b -> b.image().size(iconSmall).scaling(Scaling.fit).update(i -> i.setDrawable(get.get().uiIcon)),
() -> showContentSelect(null, set, b -> (field != null && !field.isAnnotationPresent(Researchable.class)) || b.techNode != null)
).fill().pad(4)).growX().fillY();
});
setProvider(Block.class, (type, cons) -> cons.get(Blocks.copperWall));
setInterpreter(Block.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
cont.table(t -> t.left().button(
b -> b.image().size(iconSmall).update(i -> i.setDrawable(get.get().uiIcon)),
() -> showContentSelect(ContentType.block, set, b -> (field != null && !field.isAnnotationPresent(Synthetic.class)) || b.synthetic())
).fill().pad(4f)).growX().fillY();
});
setProvider(Item.class, (type, cons) -> cons.get(Items.copper));
setInterpreter(Item.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
cont.table(t -> t.left().button(
b -> b.image().size(iconSmall).update(i -> i.setDrawable(get.get().uiIcon)),
() -> showContentSelect(ContentType.item, set, item -> true)
).fill().pad(4f)).growX().fillY();
});
setProvider(UnitType.class, (type, cons) -> cons.get(UnitTypes.dagger));
setInterpreter(UnitType.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
cont.table(t -> t.left().button(
b -> b.image().size(iconSmall).update(i -> i.setDrawable(get.get().uiIcon)),
() -> showContentSelect(ContentType.unit, set, unit -> true)
).fill().pad(4f)).growX().fillY();
});
setProvider(Team.class, (type, cons) -> cons.get(Team.sharded));
setInterpreter(Team.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
cont.table(t -> t.left().button(
b -> b.image(Tex.whiteui).size(iconSmall).update(i -> i.setColor(get.get().color)),
() -> showTeamSelect(set)
).fill().pad(4f)).growX().fillY();
});
setProvider(Color.class, (type, cons) -> cons.get(Pal.accent.cpy()));
setInterpreter(Color.class, (cont, name, type, field, remover, indexer, get, set) -> {
var out = get.get();
name(cont, name, remover, indexer);
cont.table(t -> t.left().button(
b -> b.stack(new Image(Tex.alphaBg), new Image(Tex.whiteui){{
update(() -> setColor(out));
}}).grow(),
Styles.squarei,
() -> ui.picker.show(out, res -> set.get(out.set(res)))
).margin(4f).pad(4f).size(50f)).growX().fillY();
});
setProvider(Vec2.class, (type, cons) -> cons.get(new Vec2()));
setInterpreter(Vec2.class, (cont, name, type, field, remover, indexer, get, set) -> {
var obj = get.get();
name(cont, name, remover, indexer);
cont.table(t -> {
boolean isInt = type.raw == int.class;
FieldInterpreter in = getInterpreter(float.class);
if(isInt) in = getInterpreter(int.class);
in.build(
t, "x", new TypeInfo(isInt ? int.class : float.class),
field, null, null,
isInt ? () -> (int)obj.x : () -> obj.x,
res -> {
obj.x = isInt ? (Integer)res : (Float)res;
set.get(obj);
}
);
in.build(
t.row(), "y", new TypeInfo(isInt ? int.class : float.class),
field, null, null,
isInt ? () -> (int)obj.y : () -> obj.y,
res -> {
obj.y = isInt ? (Integer)res : (Float)res;
set.get(obj);
}
);
}).growX().fillY();
});
setProvider(Point2.class, (type, cons) -> cons.get(new Point2()));
setInterpreter(Point2.class, (cont, name, type, field, remover, indexer, get, set) -> {
var obj = get.get();
var vec = new Vec2(obj.x, obj.y);
getInterpreter(Vec2.class).build(
cont, name, new TypeInfo(int.class),
field, remover, indexer,
() -> vec,
res -> {
vec.set(res);
set.get(obj.set((int)vec.x, (int)vec.y));
}
);
});
// Types that have a provider, but delegate to the default interpreter.
setProvider(MapObjective.class, (type, cons) -> new BaseDialog("@add"){{
cont.pane(p -> {
p.background(Tex.button);
p.marginRight(14f);
p.defaults().size(195f, 56f);
int i = 0;
for(var gen : MapObjectives.allObjectiveTypes){
var obj = gen.get();
p.button(obj.typeName(), Styles.flatt, () -> {
cons.get(obj);
hide();
}).with(Table::left).get().getLabelCell().growX().left().padLeft(5f).labelAlign(Align.left);
if(++i % 3 == 0) p.row();
}
}).scrollX(false);
addCloseButton();
show();
}});
setProvider(ObjectiveMarker.class, (type, cons) -> new BaseDialog("@add"){{
cont.pane(p -> {
p.background(Tex.button);
p.marginRight(14f);
p.defaults().size(195f, 56f);
int i = 0;
for(var gen : MapObjectives.allMarkerTypes){
var marker = gen.get();
p.button(marker.typeName(), Styles.flatt, () -> {
cons.get(marker);
hide();
}).with(Table::left).get().getLabelCell().growX().left().padLeft(5f).labelAlign(Align.left);
if(++i % 3 == 0) p.row();
}
}).scrollX(false);
addCloseButton();
show();
}});
setInterpreter(Vertices.class, float[].class, (cont, name, type, field, remover, indexer, get, set) -> cont.table(main -> {
float[] data = get.get();
name(cont, name, remover, indexer);
cont.table(t -> {
t.left().defaults().left();
String[] names = {"x", "y", "color", "u", "v"};
int stride = 6;
int vertices = data.length / stride;
for(int i = 0; i < vertices; i++){
int offset = i * stride;
t.table(row -> {
for(int j = 0; j < names.length; j++){
int index = offset + j;
if("color".equals(names[j])) {
getInterpreter(Color.class).build(row, names[j], new TypeInfo(Color.class), null, null, null, () -> new Color().abgr8888(data[index]), value -> data[index] = value.toFloatBits());
}else{
float scale = j <= 1 ? tilesize : 1;
getInterpreter(float.class).build(row, names[j], new TypeInfo(float.class), null, null, null, () -> data[index] / scale, value -> data[index] = value * scale);
}
row.add().pad(4);
}
}).row();
}
});
}));
// Types that use the default interpreter. It would be nice if all types could use it, but I don't know how to reliably prevent classes like [? extends Content] from using it.
for(var obj : MapObjectives.allObjectiveTypes) setInterpreter(obj.get().getClass(), defaultInterpreter());
for(var mark : MapObjectives.allMarkerTypes) setInterpreter(mark.get().getClass(), defaultInterpreter());
// Annotated field interpreters.
setInterpreter(LabelFlag.class, byte.class, (cont, name, type, field, remover, indexer, get, set) -> {
name(cont, name, remover, indexer);
cont.table(t -> {
t.left().defaults().left();
byte
value = get.get(),
bg = WorldLabel.flagBackground, out = WorldLabel.flagOutline;
t.check("@marker.background", (value & bg) == bg, res -> set.get((byte)(res ? value | bg : value & ~bg)))
.growX().fillY()
.padTop(4f).padBottom(4f).get().getLabelCell().growX();
t.row();
t.check("@marker.outline", (value & out) == out, res -> set.get((byte)(res ? value | out : value & ~out)))
.growX().fillY().get().getLabelCell().growX();
}).growX().fillY();
});
// Special data structure interpreters.
// Instantiate default `Seq`s with a reflectively allocated array.
setProvider(Seq.class, (type, cons) -> cons.get(new Seq<>(type.element.raw)));
setInterpreter(Seq.class, (cont, name, type, field, remover, indexer, get, set) -> cont.table(main -> {
Runnable[] rebuild = {null};
var arr = get.get();
main.margin(0f, 10f, 0f, 10f);
var header = main.table(Tex.button, t -> {
t.left();
t.margin(10f);
if(name.length() > 0) t.add(name + ":").color(Pal.accent);
t.add().growX();
if(remover != null) t.button(Icon.trash, Styles.emptyi, remover).fill().padRight(4f);
if(indexer != null){
t.button(Icon.upOpen, Styles.emptyi, () -> indexer.get(true)).fill().padRight(4f);
t.button(Icon.downOpen, Styles.emptyi, () -> indexer.get(false)).fill().padRight(4f);
}
if(!field.isAnnotationPresent(Immutable.class)) {
t.button(Icon.add, Styles.emptyi, () -> getProvider(type.element.raw).get(type.element, res -> {
arr.add(res);
rebuild[0].run();
})).fill();
}
}).growX().height(46f).pad(0f, -10f, 0f, -10f).get();
main.row().table(Tex.button, t -> rebuild[0] = () -> {
t.clear();
t.top();
if(arr.isEmpty()){
t.background(Tex.clear).margin(0f).setSize(0f);
}else{
t.background(Tex.button).margin(10f).marginTop(20f);
}
for(int i = 0, len = arr.size; i < len; i++){
int index = i;
if(index > 0) t.row();
getInterpreter((Class<Object>)arr.get(index).getClass()).build(
t, "", new TypeInfo(arr.get(index).getClass()),
field, field == null || !field.isAnnotationPresent(Immutable.class) ? () -> {
arr.remove(index);
rebuild[0].run();
} : null, field == null || !field.isAnnotationPresent(Unordered.class) ? in -> {
if(in && index > 0){
arr.swap(index, index - 1);
rebuild[0].run();
}else if(!in && index < len - 1){
arr.swap(index, index + 1);
rebuild[0].run();
}
} : null,
() -> arr.get(index),
res -> {
arr.set(index, res);
set.get(arr);
}
);
}
set.get(arr);
}).padTop(-10f).growX().fillY();
rebuild[0].run();
header.toFront();
}).growX().fillY().pad(4f).colspan(2));
// Reserved for array types that are not explicitly handled. Essentially handles it the same way as `Seq`.
setProvider(Object[].class, (type, cons) -> cons.get(Reflect.newArray(type.element.raw, 0)));
setInterpreter(Object[].class, (cont, name, type, field, remover, indexer, get, set) -> {
var arr = Seq.with(get.get());
getInterpreter(Seq.class).build(
cont, name, new TypeInfo(Seq.class, type.element),
field, remover, indexer,
() -> arr,
res -> set.get(arr.toArray(type.element.raw))
);
});
}
public static <T> FieldInterpreter<T> defaultInterpreter(){
return (cont, name, type, field, remover, indexer, get, set) -> cont.table(main -> {
main.margin(0f, 10f, 0f, 10f);
var header = main.table(Tex.button, t -> {
t.left();
t.margin(10f);
if(name.length() > 0) t.add(name + ":").color(Pal.accent);
t.add().growX();
Cell<ImageButton> remove = null;
if(remover != null) remove = t.button(Icon.trash, Styles.emptyi, remover).fill();
if(indexer != null){
if(remove != null) remove.padRight(4f);
t.button(Icon.upOpen, Styles.emptyi, () -> indexer.get(true)).fill().padRight(4f);
t.button(Icon.downOpen, Styles.emptyi, () -> indexer.get(false)).fill();
}
}).growX().height(46f).pad(0f, -10f, -0f, -10f).get();
main.row().table(Tex.button, t -> {
t.left();
t.top().margin(10f).marginTop(20f);
t.defaults().minHeight(40f).left();
var obj = get.get();
int i = 0;
for(var e : JsonIO.json.getFields(type.raw).values()){
if(i++ > 0) t.row();
var f = e.field;
var ft = f.getType();
int mods = f.getModifiers();
if(!Modifier.isPublic(mods) || (Modifier.isFinal(mods) && (
String.class.isAssignableFrom(ft) ||
unbox(ft).isPrimitive()
))) continue;
var anno = Structs.find(f.getDeclaredAnnotations(), a -> hasInterpreter(a.annotationType(), ft));
getInterpreter(anno == null ? Override.class : anno.annotationType(), ft).build(
t, f.getName(), new TypeInfo(f),
f, null, null,
() -> Reflect.get(obj, f),
Modifier.isFinal(mods) ? res -> {} : res -> Reflect.set(obj, f, res)
);
}
}).padTop(-10f).growX().fillY();
header.toFront();
}).growX().fillY().pad(4f).colspan(2);
}
public static void name(Table cont, CharSequence name, @Nullable Runnable remover, @Nullable Boolc indexer){
if(indexer != null || remover != null){
cont.table(t -> {
if(remover != null) t.button(Icon.trash, Styles.emptyi, remover).fill().padRight(4f);
if(indexer != null){
t.button(Icon.upOpen, Styles.emptyi, () -> indexer.get(true)).fill().padRight(4f);
t.button(Icon.downOpen, Styles.emptyi, () -> indexer.get(false)).fill().padRight(4f);
}
}).fill();
}else{
cont.add(name + ": ");
}
}
public MapObjectivesDialog(){
super("@editor.objectives");
clear();
margin(0f);
stack(
new Image(Styles.black5),
canvas = new MapObjectivesCanvas(),
new Table(){{
buttons.defaults().size(160f, 64f).pad(2f);
buttons.button("@back", Icon.left, MapObjectivesDialog.this::hide);
buttons.button("@add", Icon.add, () -> getProvider(MapObjective.class).get(new TypeInfo(MapObjective.class), canvas::query));
buttons.button("@waves.edit", Icon.edit, () -> {
BaseDialog dialog = new BaseDialog("@waves.edit");
dialog.addCloseButton();
dialog.setFillParent(false);
dialog.cont.table(Tex.button, t -> {
var style = Styles.cleart;
t.defaults().size(280f, 64f).pad(2f);
t.button("@waves.copy", Icon.copy, style, () -> {
ui.showInfoFade("@copied");
Core.app.setClipboardText(JsonIO.write(new MapObjectives(canvas.objectives)));
dialog.hide();
}).disabled(b -> canvas.objectives.isEmpty()).marginLeft(12f).row();
t.button("@waves.load", Icon.download, style, () -> {
try{
rebuildObjectives(new Seq<>(JsonIO.read(MapObjectives.class, Core.app.getClipboardText()).all));
}catch(Exception e){
Log.err(e);
ui.showErrorMessage("@waves.invalid");
}
dialog.hide();
}).disabled(Core.app.getClipboardText() == null || !Core.app.getClipboardText().startsWith("[")).marginLeft(12f).row();
t.button("@clear", Icon.none, style, () -> ui.showConfirm("@confirm", "@settings.clear.confirm", () -> {
rebuildObjectives(new Seq<>());
dialog.hide();
})).marginLeft(12f).row();
});
dialog.show();
});
if(mobile){
buttons.button("@cancel", Icon.cancel, canvas::stopQuery).visible(() -> canvas.isQuerying());
buttons.button("@ok", Icon.ok, canvas::placeQuery).visible(() -> canvas.isQuerying());
}
setFillParent(true);
margin(3f);
add(titleTable).growX().fillY();
row().add().grow();
row().add(buttons).fill();
addCloseListener();
}}
).grow().pad(0f).margin(0f);
hidden(() -> {
out.get(canvas.objectives);
out = arr -> {};
});
}
public void show(Seq<MapObjective> objectives, Cons<Seq<MapObjective>> out){
this.out = out;
rebuildObjectives(objectives);
show();
}
public void rebuildObjectives(Seq<MapObjective> objectives){
canvas.clearObjectives();
if(
objectives.any() && (
// If the objectives were previously programmatically made...
objectives.contains(obj -> obj.editorX == -1 || obj.editorY == -1) ||
// ... or some idiot somehow made it not work...
objectives.contains(obj -> !canvas.tilemap.createTile(obj))
)){
// ... then rebuild the structure.
canvas.clearObjectives();
// This is definitely NOT a good way to do it, but only insane people or people from the distant past would actually encounter this anyway.
int w = objWidth + 2,
len = objectives.size * w,
columns = objectives.size,
rows = 1;
if(len > bounds){
rows = len / bounds;
columns = bounds / w;
}
int i = 0;
loop:
for(int y = 0; y < rows; y++){
for(int x = 0; x < columns; x++){
if(canvas.tilemap.createTile(x * w, y, objectives.get(i))){
i++;
}
if(i >= objectives.size) break loop;
}
}
}
canvas.objectives.set(objectives);
}
public static <T extends UnlockableContent> void showContentSelect(@Nullable ContentType type, Cons<T> cons, Boolf<T> check){
BaseDialog dialog = new BaseDialog("");
dialog.cont.pane(Styles.noBarPane, t -> {
int i = 0;
for(var content : (type == null ? content.blocks().copy().<UnlockableContent>as()
.add(content.items())
.add(content.liquids())
.add(content.units()) :
content.getBy(type).<UnlockableContent>as()
)){
if(content.isHidden() || !check.get((T)content)) continue;
t.image(content == Blocks.air ? Icon.none.getRegion() : content.uiIcon).size(iconMed).pad(3).scaling(Scaling.fit)
.with(b -> b.addListener(new HandCursorListener()))
.tooltip(content.localizedName).get().clicked(() -> {
cons.get((T)content);
dialog.hide();
});
if(++i % 10 == 0) t.row();
}
}).fill();
dialog.closeOnBack();
dialog.show();
}
public static void showTeamSelect(Cons<Team> cons){
BaseDialog dialog = new BaseDialog("");
for(var team : Team.baseTeams){
dialog.cont.image(Tex.whiteui).size(iconMed).color(team.color).pad(4)
.with(i -> i.addListener(new HandCursorListener()))
.tooltip(team.localized()).get().clicked(() -> {
cons.get(team);
dialog.hide();
});
}
dialog.closeOnBack();
dialog.show();
}
public static Class<?> unbox(Class<?> boxed){
return switch(boxed.getSimpleName()){
case "Boolean" -> boolean.class;
case "Byte" -> byte.class;
case "Character" -> char.class;
case "Short" -> short.class;
case "Integer" -> int.class;
case "Long" -> long.class;
case "Float" -> float.class;
case "Double" -> double.class;
default -> boxed;
};
}
public static <T> void setInterpreter(Class<T> type, FieldInterpreter<? super T> interpreter){
setInterpreter(Override.class, type, interpreter);
}
public static <T> void setInterpreter(Class<? extends Annotation> anno, Class<T> type, FieldInterpreter<? super T> interpreter){
interpreters.get(anno, ObjectMap::new).put(type, interpreter);
}
public static boolean hasInterpreter(Class<?> type){
return hasInterpreter(Override.class, type);
}
public static boolean hasInterpreter(Class<? extends Annotation> anno, Class<?> type){
return interpreters.get(anno, ObjectMap::new).containsKey(unbox(type));
}
public static <T> FieldInterpreter<T> getInterpreter(Class<T> type){
return getInterpreter(Override.class, type);
}
public static <T> FieldInterpreter<T> getInterpreter(Class<? extends Annotation> anno, Class<T> type){
if(hasInterpreter(anno, type)){
return (FieldInterpreter<T>)interpreters.get(anno, ObjectMap::new).get(unbox(type));
}else if(hasInterpreter(Override.class, type)){
return (FieldInterpreter<T>)interpreters.get(Override.class, ObjectMap::new).get(unbox(type));
}else if(type.isArray() && !type.getComponentType().isPrimitive()){
return (FieldInterpreter<T>)(hasInterpreter(anno, Object[].class)
? interpreters.get(anno).get(Object[].class)
: interpreters.get(Override.class).get(Object[].class)
);
}else{
throw new IllegalArgumentException("Interpreter for type " + type + " not set up yet.");
}
}
public static <T> void setProvider(Class<T> type, FieldProvider<T> provider){
providers.put(unbox(type), provider);
}
public static boolean hasProvider(Class<?> type){
return providers.containsKey(unbox(type));
}
public static <T> FieldProvider<T> getProvider(Class<T> type){
return (FieldProvider<T>)providers.getThrow(unbox(type), () -> new IllegalArgumentException("Provider for type " + type + " not set up yet."));
}
public interface FieldInterpreter<T>{
/**
* Builds the interpreter for (not-necessarily) a possibly annotated field. Implementations must add exactly
* 2 columns to the table.
* @param name May be empty.
* @param remover If this callback is not {@code null}, this interpreter should add a button that invokes the
* callback to signal element removal.
* @param indexer If this callback is not {@code null}, this interpreter should add 2 buttons that invoke the
* callback to signal element rearrangement with the following values:<ul>
* <li>{@code true}: Swap element with previous index.</li>
* <li>{@code false}: Swap element with next index.</li>
* </ul>
*/
void build(Table cont,
CharSequence name, TypeInfo type,
@Nullable Field field,
@Nullable Runnable remover, @Nullable Boolc indexer,
Prov<T> get, Cons<T> set);
}
public interface FieldProvider<T>{
void get(TypeInfo type, Cons<T> cons);
}
/**
* Stores parameterized or array type information for convenience.
* For {@code A[]}: {@link #raw} is {@code A[]}, {@link #element} is {@code A}, {@link #key} is {@code null}.
* For {@code Seq<A>}: {@link #raw} is {@link Seq}, {@link #element} is {@code A}, {@link #key} is {@code null}.
* For {@code ObjectMap<A, B>}: {@link #raw} is {@link ObjectMap}, {@link #element} is {@code B}, {@link #key} is {@code A}.
*/
public static class TypeInfo{
public final Class<?> raw;
public final TypeInfo element, key;
public TypeInfo(Field field){
this(field.getType(), field.getGenericType());
}
public TypeInfo(Class<?> raw){
this(raw, raw);
}
/** Use with care! */
public TypeInfo(Class<?> raw, TypeInfo element){
this.raw = unbox(raw);
this.element = element;
key = null;
}
public TypeInfo(Class<?> raw, Type generic){
this.raw = unbox(raw);
if(raw.isArray()){
key = null;
element = new TypeInfo(raw.getComponentType(), generic instanceof GenericArrayType type ? type.getGenericComponentType() : raw.getComponentType());
}else if(Seq.class.isAssignableFrom(raw)){
key = null;
element = getParam(generic, 0);
}else if(ObjectMap.class.isAssignableFrom(raw)){
key = getParam(generic, 0);
element = getParam(generic, 1);
}else{
key = element = null;
}
}
public static TypeInfo getParam(Type generic, int index){
Type[] params =
generic instanceof ParameterizedType type ? type.getActualTypeArguments() :
generic instanceof GenericDeclaration type ? type.getTypeParameters() : null;
if(params != null && index < params.length){
var target = params[index];
return new TypeInfo(raw(target), target);
}
return new TypeInfo(Object.class, Object.class);
}
public static Class<?> raw(Type type){
if(type instanceof Class<?> c) return c;
if(type instanceof ParameterizedType c) return (Class<?>)c.getRawType();
if(type instanceof GenericArrayType c) return Reflect.newArray(raw(c.getGenericComponentType()), 0).getClass();
if(type instanceof TypeVariable<?> c) return raw(c.getBounds()[0]);
return Object.class;
}
}
}
@@ -0,0 +1,155 @@
package mindustry.editor;
import arc.scene.style.*;
import arc.scene.ui.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import mindustry.*;
import mindustry.content.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
import mindustry.world.*;
import mindustry.world.blocks.environment.*;
import mindustry.world.blocks.logic.*;
import mindustry.world.blocks.logic.LogicBlock.*;
import static mindustry.Vars.*;
public class MapProcessorsDialog extends BaseDialog{
private IconSelectDialog iconSelect = new IconSelectDialog(true);
private TextField search;
private Seq<Building> processors = new Seq<>();
private Table list;
public MapProcessorsDialog(){
super("@editor.worldprocessors");
shown(this::setup);
addCloseButton();
buttons.button("@add", Icon.add, () -> {
boolean foundAny = false;
outer:
for(int y = 0; y < Vars.world.height(); y++){
for(int x = 0; x < Vars.world.width(); x++){
Tile tile = Vars.world.rawTile(x, y);
if(!tile.synthetic()){
foundAny = true;
tile.setNet(Blocks.worldProcessor, Team.sharded, 0);
if(ui.editor.isShown()){
Vars.editor.renderer.updatePoint(x, y);
}
break outer;
}
}
}
if(!foundAny){
ui.showErrorMessage("@editor.worldprocessors.nospace");
}else{
setup();
}
}).size(210f, 64f);
cont.top();
getCell(cont).grow();
cont.table(s -> {
s.image(Icon.zoom).padRight(8);
search = s.field(null, text -> rebuild()).growX().get();
search.setMessageText("@players.search");
}).width(440f).fillX().padBottom(4).row();
cont.pane(t -> {
list = t;
});
}
private void rebuild(){
list.clearChildren();
if(processors.isEmpty()){
list.add("@editor.worldprocessors.none");
}else{
Table t = list;
var text = search.getText().toLowerCase();
t.defaults().pad(4f);
float h = 50f;
for(var build : processors){
if(build instanceof LogicBuild log && (text.isEmpty() || (log.tag != null && log.tag.toLowerCase().contains(text)))){
t.button(log.iconTag == 0 ? Styles.none : new TextureRegionDrawable(Fonts.getLargeIcon(Fonts.unicodeToName(log.iconTag))), Styles.graySquarei, iconMed, () -> {
iconSelect.show(ic -> {
log.iconTag = (char)ic;
rebuild();
});
}).size(h);
t.button((log.tag == null ? "<no name>\n" : "[accent]" + log.tag + "\n") + "[lightgray][[" + log.tile.x + ", " + log.tile.y + "]", Styles.grayt, () -> {
//TODO: bug: if you edit name inside of the edit dialog, it won't show up in the list properly
log.showEditDialog(true);
}).size(Vars.mobile ? 390f : 450f, h).margin(10f).with(b -> {
b.getLabel().setAlignment(Align.left, Align.left);
});
t.button(Icon.pencil, Styles.graySquarei, Vars.iconMed, () -> {
ui.showTextInput("", "@editor.name", LogicBlock.maxNameLength, log.tag == null ? "" : log.tag, tag -> {
//bypass configuration and set it directly in case privileged checks mess things up
log.tag = tag;
setup();
});
}).size(h);
if(Vars.state.isGame() && state.isEditor()){
t.button(Icon.eyeSmall, Styles.graySquarei, Vars.iconMed, () -> {
hide();
control.input.config.showConfig(build);
control.input.panCamera(Tmp.v1.set(build));
}).size(h);
}
t.button(Icon.trash, Styles.graySquarei, iconMed, () -> {
ui.showConfirm("@editor.worldprocessors.delete.confirm", () -> {
boolean surrounded = true;
for(int i = 0; i < 4; i++){
Tile other = build.tile.nearby(i);
if(other != null && !(other.block().privileged || other.block().isStatic())){
surrounded = false;
break;
}
}
if(surrounded){
build.tile.setNet(build.tile.floor().wall instanceof StaticWall ? build.tile.floor().wall : Blocks.stoneWall);
}else{
build.tile.setNet(Blocks.air);
}
processors.remove(build);
rebuild();
});
}).size(h);
t.row();
}
}
}
}
private void setup(){
processors.clear();
//scan the entire world for processor (Groups.build can be empty, indexer is probably inaccurate)
Vars.world.tiles.eachTile(t -> {
if(t.isCenter() && t.block() == Blocks.worldProcessor){
processors.add(t.build);
}
});
rebuild();
}
}
+54 -26
View File
@@ -10,6 +10,7 @@ import mindustry.content.*;
import mindustry.game.*;
import mindustry.graphics.*;
import mindustry.world.*;
import mindustry.world.blocks.environment.*;
import static mindustry.Vars.*;
@@ -18,14 +19,8 @@ public class MapRenderer implements Disposable{
private IndexedRenderer[][] chunks;
private IntSet updates = new IntSet();
private IntSet delayedUpdates = new IntSet();
private MapEditor editor;
private TextureRegion clearEditor;
private int width, height;
private Texture texture;
public MapRenderer(MapEditor editor){
this.editor = editor;
this.texture = Core.atlas.find("clear-editor").texture;
}
public void resize(int width, int height){
updates.clear();
@@ -52,6 +47,7 @@ public class MapRenderer implements Disposable{
public void draw(float tx, float ty, float tw, float th){
Draw.flush();
clearEditor = Core.atlas.find("clear-editor");
updates.each(i -> render(i % width, i / width));
updates.clear();
@@ -64,6 +60,8 @@ public class MapRenderer implements Disposable{
return;
}
var texture = Core.atlas.find("clear-editor").texture;
for(int x = 0; x < chunks.length; x++){
for(int y = 0; y < chunks[0].length; y++){
IndexedRenderer mesh = chunks[x][y];
@@ -85,6 +83,7 @@ public class MapRenderer implements Disposable{
}
public void updateAll(){
clearEditor = Core.atlas.find("clear-editor");
for(int x = 0; x < width; x++){
for(int y = 0; y < height; y++){
render(x, y);
@@ -92,13 +91,22 @@ public class MapRenderer implements Disposable{
}
}
private TextureRegion getIcon(Block wall, int index){
return !wall.editorIcon().found() ?
clearEditor : wall.variants > 0 ?
wall.editorVariantRegions()[Mathf.randomSeed(index, 0, wall.editorVariantRegions().length - 1)] :
wall.editorIcon();
}
private void render(int wx, int wy){
int x = wx / chunkSize, y = wy / chunkSize;
if(x >= chunks.length || y >= chunks[0].length) return;
IndexedRenderer mesh = chunks[x][y];
Tile tile = editor.tiles().getn(wx, wy);
Team team = tile.team();
Block floor = tile.floor();
Floor floor = tile.floor();
Floor overlay = tile.overlay();
Block wall = tile.block();
TextureRegion region;
@@ -106,15 +114,23 @@ public class MapRenderer implements Disposable{
int idxWall = (wx % chunkSize) + (wy % chunkSize) * chunkSize;
int idxDecal = (wx % chunkSize) + (wy % chunkSize) * chunkSize + chunkSize * chunkSize;
boolean center = tile.isCenter();
boolean useSyntheticWall = wall.synthetic() || overlay.wallOre;
if(wall != Blocks.air && wall.synthetic()){
region = !Core.atlas.isFound(wall.editorIcon()) || !center ? Core.atlas.find("clear-editor") : wall.editorIcon();
//draw synthetic wall or floor OR standard wall if wall ore
if(wall != Blocks.air && useSyntheticWall){
region = !center ? clearEditor : getIcon(wall, idxWall);
float width = region.width * Draw.scl, height = region.height * Draw.scl;
float width = region.width * region.scl(), height = region.height * region.scl(), ox = wall.offset + (tilesize - width) / 2f, oy = wall.offset + (tilesize - height) / 2f;
//force fit to tile
if(overlay.wallOre && !wall.synthetic()){
width = height = tilesize;
ox = oy = 0f;
}
mesh.draw(idxWall, region,
wx * tilesize + wall.offset + (tilesize - width) / 2f,
wy * tilesize + wall.offset + (tilesize - height) / 2f,
wx * tilesize + ox,
wy * tilesize + oy,
width, height,
tile.build == null || !wall.rotate ? 0 : tile.build.rotdeg());
}else{
@@ -123,27 +139,39 @@ public class MapRenderer implements Disposable{
mesh.draw(idxWall, region, wx * tilesize, wy * tilesize, 8, 8);
}
float offsetX = -(wall.size / 3) * tilesize, offsetY = -(wall.size / 3) * tilesize;
float offsetX = -((wall.size + 1) / 3) * tilesize, offsetY = -((wall.size + 1) / 3) * tilesize;
//draw non-synthetic wall or ore
if((wall.update || wall.destructible) && center){
mesh.setColor(team.color);
region = Core.atlas.find("block-border-editor");
}else if(!wall.synthetic() && wall != Blocks.air && center){
region = !Core.atlas.isFound(wall.editorIcon()) ? Core.atlas.find("clear-editor") : wall.editorIcon();
offsetX = tilesize / 2f - region.width / 2f * Draw.scl;
offsetY = tilesize / 2f - region.height / 2f * Draw.scl;
}else if(wall == Blocks.air && !tile.overlay().isAir()){
region = tile.overlay().editorVariantRegions()[Mathf.randomSeed(idxWall, 0, tile.overlay().editorVariantRegions().length - 1)];
if(wall.size == 2){
offsetX += tilesize;
offsetY += tilesize;
}
}else if(!useSyntheticWall && wall != Blocks.air && center){
region = getIcon(wall, idxWall);
if(wall == Blocks.cliff){
mesh.setColor(Tmp.c1.set(floor.mapColor).mul(1.6f));
region = ((Cliff)Blocks.cliff).editorCliffs[tile.data & 0xff];
}
offsetX = tilesize / 2f - region.width * region.scl() / 2f;
offsetY = tilesize / 2f - region.height * region.scl() / 2f;
}else if((wall == Blocks.air || overlay.wallOre) && !overlay.isAir()){
if(floor.isLiquid){
mesh.setColor(Tmp.c1.set(1f, 1f, 1f, floor.overlayAlpha));
}
region = overlay.editorVariantRegions()[Mathf.randomSeed(idxWall, 0, tile.overlay().editorVariantRegions().length - 1)];
}else{
region = Core.atlas.find("clear-editor");
region = clearEditor;
}
float width = region.width * Draw.scl, height = region.height * Draw.scl;
float width = region.width * region.scl(), height = region.height * region.scl();
if(!wall.synthetic() && wall != Blocks.air && !wall.isMultiblock()){
offsetX = 0;
offsetY = 0;
width = tilesize;
height = tilesize;
offsetX = offsetY = 0f;
width = height = tilesize;
}
mesh.draw(idxDecal, region, wx * tilesize + offsetX, wy * tilesize + offsetY, width, height);
+28 -7
View File
@@ -1,18 +1,22 @@
package mindustry.editor;
import arc.func.*;
import arc.math.*;
import arc.scene.ui.TextField.*;
import arc.scene.ui.layout.*;
import arc.util.*;
import mindustry.ui.dialogs.*;
public class MapResizeDialog extends BaseDialog{
public static int minSize = 50, maxSize = 500, increment = 50;
int width, height;
import static mindustry.Vars.*;
public MapResizeDialog(MapEditor editor, Intc2 cons){
public class MapResizeDialog extends BaseDialog{
public static int minSize = 50, maxSize = 600, increment = 50;
int width, height, shiftX, shiftY;
public MapResizeDialog(ResizeListener cons){
super("@editor.resizemap");
closeOnBack();
shown(() -> {
cont.clear();
width = editor.width();
@@ -27,10 +31,23 @@ public class MapResizeDialog extends BaseDialog{
table.field((w ? width : height) + "", TextFieldFilter.digitsOnly, value -> {
int val = Integer.parseInt(value);
if(w) width = val; else height = val;
}).valid(value -> Strings.canParsePositiveInt(value) && Integer.parseInt(value) <= maxSize && Integer.parseInt(value) >= minSize).addInputDialog(3);
}).valid(value -> Strings.canParsePositiveInt(value) && Integer.parseInt(value) <= maxSize && Integer.parseInt(value) >= minSize).maxTextLength(3);
table.row();
}
for(boolean x : Mathf.booleans){
table.add(x ? "@editor.shiftx" : "@editor.shifty").padRight(8f);
table.defaults().height(60f).padTop(8);
table.field((x ? shiftX : shiftY) + "", value -> {
int val = Integer.parseInt(value);
if(x) shiftX = val; else shiftY = val;
}).valid(Strings::canParseInt).maxTextLength(4);
table.row();
}
cont.row();
cont.add(table);
@@ -39,8 +56,12 @@ public class MapResizeDialog extends BaseDialog{
buttons.defaults().size(200f, 50f);
buttons.button("@cancel", this::hide);
buttons.button("@ok", () -> {
cons.get(width, height);
cons.get(width, height, shiftX, shiftY);
hide();
});
}
public interface ResizeListener{
void get(int width, int height, int shiftX, int shiftY);
}
}
+23 -18
View File
@@ -9,7 +9,6 @@ import arc.math.*;
import arc.math.geom.*;
import arc.scene.*;
import arc.scene.event.*;
import arc.scene.ui.*;
import arc.scene.ui.layout.*;
import arc.util.*;
import mindustry.graphics.*;
@@ -19,7 +18,6 @@ import mindustry.ui.*;
import static mindustry.Vars.*;
public class MapView extends Element implements GestureListener{
private MapEditor editor;
EditorTool tool = EditorTool.pencil;
private float offsetx, offsety;
private float zoom = 1f;
@@ -35,12 +33,12 @@ public class MapView extends Element implements GestureListener{
float mousex, mousey;
EditorTool lastTool;
public MapView(MapEditor editor){
this.editor = editor;
public MapView(){
for(int i = 0; i < MapEditor.brushSizes.length; i++){
float size = MapEditor.brushSizes[i];
brushPolygons[i] = Geometry.pixelCircle(size, (index, x, y) -> Mathf.dst(x, y, index, index) <= index - 0.5f);
float mod = size % 1f;
brushPolygons[i] = Geometry.pixelCircle(size, (index, x, y) -> Mathf.dst(x, y, index - mod, index - mod) <= size - 0.5f);
}
Core.input.getInputProcessors().insert(0, new GestureDetector(20, 0.5f, 2, 0.15f, this));
@@ -92,7 +90,7 @@ public class MapView extends Element implements GestureListener{
lasty = p.y;
startx = p.x;
starty = p.y;
tool.touched(editor, p.x, p.y);
tool.touched(p.x, p.y);
firstTouch.set(p);
if(tool.edit){
@@ -115,7 +113,7 @@ public class MapView extends Element implements GestureListener{
if(tool == EditorTool.line){
ui.editor.resetSaved();
tool.touchedLine(editor, startx, starty, p.x, p.y);
tool.touchedLine(startx, starty, p.x, p.y);
}
editor.flushOp();
@@ -136,7 +134,7 @@ public class MapView extends Element implements GestureListener{
if(drawing && tool.draggable && !(p.x == lastx && p.y == lasty)){
ui.editor.resetSaved();
Bresenham2.line(lastx, lasty, p.x, p.y, (cx, cy) -> tool.touched(editor, cx, cy));
Bresenham2.line(lastx, lasty, p.x, p.y, (cx, cy) -> tool.touched(cx, cy));
}
if(tool == EditorTool.line && tool.mode == 1){
@@ -179,26 +177,26 @@ public class MapView extends Element implements GestureListener{
public void act(float delta){
super.act(delta);
if(Core.scene.getKeyboardFocus() == null || !(Core.scene.getKeyboardFocus() instanceof TextField) && !Core.input.keyDown(KeyCode.controlLeft)){
if(Core.scene.getKeyboardFocus() == null || !Core.scene.hasField() && !Core.input.keyDown(KeyCode.controlLeft)){
float ax = Core.input.axis(Binding.move_x);
float ay = Core.input.axis(Binding.move_y);
offsetx -= ax * 15f / zoom;
offsety -= ay * 15f / zoom;
offsetx -= ax * 15 * Time.delta / zoom;
offsety -= ay * 15 * Time.delta / zoom;
}
if(Core.input.keyTap(KeyCode.shiftLeft)){
if(Core.input.keyTap(KeyCode.shiftLeft) || Core.input.keyTap(KeyCode.altLeft)){
lastTool = tool;
tool = EditorTool.pick;
}
if(Core.input.keyRelease(KeyCode.shiftLeft) && lastTool != null){
if((Core.input.keyRelease(KeyCode.shiftLeft) || Core.input.keyRelease(KeyCode.altLeft)) && lastTool != null){
tool = lastTool;
lastTool = null;
}
if(Core.scene.getScrollFocus() != this) return;
zoom += Core.input.axis(KeyCode.scroll) / 10f * zoom;
zoom += Core.input.axis(Binding.zoom) / 10f * zoom;
clampZoom();
}
@@ -243,14 +241,14 @@ public class MapView extends Element implements GestureListener{
image.setImageSize(editor.width(), editor.height());
if(!ScissorStack.push(rect.set(x, y + Core.scene.marginBottom, width, height))){
if(!ScissorStack.push(rect.set(x + Core.scene.marginLeft, y + Core.scene.marginBottom, width, height))){
return;
}
Draw.color(Pal.remove);
Lines.stroke(2f);
Lines.rect(centerx - sclwidth / 2 - 1, centery - sclheight / 2 - 1, sclwidth + 2, sclheight + 2);
editor.renderer.draw(centerx - sclwidth / 2, centery - sclheight / 2 + Core.scene.marginBottom, sclwidth, sclheight);
editor.renderer.draw(centerx - sclwidth / 2 + Core.scene.marginLeft, centery - sclheight / 2 + Core.scene.marginBottom, sclwidth, sclheight);
Draw.reset();
if(grid){
@@ -258,6 +256,13 @@ public class MapView extends Element implements GestureListener{
image.setBounds(centerx - sclwidth / 2, centery - sclheight / 2, sclwidth, sclheight);
image.draw();
Lines.stroke(2f);
Draw.color(Pal.bulletYellowBack);
Lines.line(centerx - sclwidth/2f, centery - sclheight/4f, centerx + sclwidth/2f, centery - sclheight/4f);
Lines.line(centerx - sclwidth/4f, centery - sclheight/2f, centerx - sclwidth/4f, centery + sclheight/2f);
Lines.line(centerx - sclwidth/2f, centery + sclheight/4f, centerx + sclwidth/2f, centery + sclheight/4f);
Lines.line(centerx + sclwidth/4f, centery - sclheight/2f, centerx + sclwidth/4f, centery + sclheight/2f);
Lines.stroke(3f);
Draw.color(Pal.accent);
Lines.line(centerx - sclwidth/2f, centery, centerx + sclwidth/2f, centery);
@@ -295,7 +300,7 @@ public class MapView extends Element implements GestureListener{
//pencil square outline
if(tool == EditorTool.pencil && tool.mode == 1){
Lines.square(v.x + scaling/2f, v.y + scaling/2f, scaling * (editor.brushSize + 0.5f));
Lines.square(v.x + scaling/2f, v.y + scaling/2f, scaling * ((editor.brushSize == 1.5f ? 1f : editor.brushSize) + 0.5f));
}else{
Lines.poly(brushPolygons[index], v.x, v.y, scaling);
}
@@ -324,7 +329,7 @@ public class MapView extends Element implements GestureListener{
return Core.scene != null && Core.scene.getKeyboardFocus() != null
&& Core.scene.getKeyboardFocus().isDescendantOf(ui.editor)
&& ui.editor.isShown() && tool == EditorTool.zoom &&
Core.scene.hit(Core.input.mouse().x, Core.input.mouse().y, true) == this;
Core.scene.getHoverElement() == this;
}
@Override
@@ -0,0 +1,108 @@
package mindustry.editor;
import arc.util.*;
import mindustry.content.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.type.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
import static mindustry.Vars.*;
public class SectorGenerateDialog extends BaseDialog{
Planet planet = Planets.erekir;
int sector = 0, seed = 0;
public SectorGenerateDialog(){
super("@editor.sectorgenerate");
setup();
}
void setup(){
cont.clear();
buttons.clear();
addCloseButton();
cont.defaults().left();
cont.add("@editor.planet").padRight(10f);
cont.button(planet.localizedName, () -> {
BaseDialog dialog = new BaseDialog("");
dialog.cont.pane(p -> {
p.background(Tex.button).margin(10f);
int i = 0;
for(var plan : content.planets()){
if(plan.generator == null || plan.sectors.size == 0 || !plan.accessible) continue;
p.button(plan.localizedName, Styles.flatTogglet, () -> {
planet = plan;
sector = Math.min(sector, planet.sectors.size - 1);
seed = 0;
dialog.hide();
}).size(110f, 45f).checked(planet == plan);
if(++i % 4 == 0){
p.row();
}
}
});
dialog.setFillParent(false);
dialog.addCloseButton();
dialog.show();
}).size(200f, 40f).get().getLabel().setText(() -> planet.localizedName);
cont.row();
cont.add("@editor.sector").padRight(10f);
cont.field(sector + "", text -> {
sector = Strings.parseInt(text);
}).width(200f).valid(text -> planet.sectors.size > Strings.parseInt(text, 99999) && Strings.parseInt(text, 9999) >= 0);
cont.row();
cont.add("@editor.seed").padRight(10f);
cont.field(seed + "", text -> {
seed = Strings.parseInt(text);
}).width(200f).valid(Strings::canParseInt);
cont.row();
cont.label(() -> "[ " + planet.sectors.get(sector).getSize() + "x" + planet.sectors.get(sector).getSize() + " ]").color(Pal.accent).center().labelAlign(Align.center).padTop(5).colspan(2);
buttons.button("@editor.apply", Icon.ok, () -> {
ui.loadAnd(() -> {
apply();
hide();
});
});
}
void apply(){
ui.loadAnd(() -> {
editor.clearOp();
editor.load(() -> {
var sectorobj = planet.sectors.get(sector);
//remove presets during generation: massive hack, but it works
var preset = sectorobj.preset;
sectorobj.preset = null;
logic.reset(); //TODO: is this a good idea? all rules and map state are cleared, but it fixes inconsistent gen
world.loadSector(sectorobj, seed, false);
sectorobj.preset = preset;
editor.updateRenderer();
state.rules.sector = null;
//clear extra filters
editor.tags.put("genfilters", "{}");
});
});
}
}
+187 -31
View File
@@ -1,8 +1,13 @@
package mindustry.editor;
import arc.*;
import arc.graphics.*;
import arc.graphics.g2d.*;
import arc.input.*;
import arc.math.*;
import arc.math.geom.*;
import arc.scene.*;
import arc.scene.event.*;
import arc.scene.ui.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
@@ -17,7 +22,6 @@ import mindustry.ui.*;
public class WaveGraph extends Table{
public Seq<SpawnGroup> groups = new Seq<>();
public int from = 0, to = 20;
private Mode mode = Mode.counts;
private int[][] values;
@@ -26,41 +30,114 @@ public class WaveGraph extends Table{
private float maxHealth;
private Table colors;
private ObjectSet<UnitType> hidden = new ObjectSet<>();
private StringBuilder countStr = new StringBuilder();
private float pan;
private float zoom = 1f;
private int from = 0, to = 20;
private int lastFrom = -1, lastTo = -1;
private float lastZoom = -1f;
private float defaultSpace = Scl.scl(40f);
private FloatSeq points = new FloatSeq(40);
public WaveGraph(){
background(Tex.pane);
scrolled((scroll) -> {
zoom -= scroll * 2f / 10f * zoom;
clampZoom();
});
touchable = Touchable.enabled;
addListener(new InputListener(){
@Override
public void enter(InputEvent event, float x, float y, int pointer, Element fromActor){
requestScroll();
}
});
addListener(new ElementGestureListener(){
@Override
public void pan(InputEvent event, float x, float y, float deltaX, float deltaY){
pan -= deltaX/zoom;
}
@Override
public void zoom(InputEvent event, float initialDistance, float distance){
if(lastZoom < 0) lastZoom = zoom;
zoom = distance / initialDistance * lastZoom;
clampZoom();
}
@Override
public void touchUp(InputEvent event, float x, float y, int pointer, KeyCode button){
lastZoom = zoom;
}
});
rect((x, y, width, height) -> {
Lines.stroke(Scl.scl(3f));
countStr.setLength(0);
Vec2 mouse = stageToLocalCoordinates(Core.input.mouse());
GlyphLayout lay = Pools.obtain(GlyphLayout.class, GlyphLayout::new);
Font font = Fonts.outline;
int maxY = switch(mode){
case counts -> nextStep(max);
case health -> nextStep((int)maxHealth);
case totals -> nextStep(maxTotal);
};
lay.setText(font, "1");
float fh = lay.height;
float offsetX = Scl.scl(30f), offsetY = Scl.scl(22f) + fh + Scl.scl(5f);
float spacing = zoom * defaultSpace;
pan = Math.max(pan, (width/2f)/zoom-defaultSpace);
float graphX = x + offsetX, graphY = y + offsetY, graphW = width - offsetX, graphH = height - offsetY;
float spacing = graphW / (values.length - 1);
float fh = lay.height;
float offsetX = 0f, offsetY = Scl.scl(22f) + fh + Scl.scl(5f);
float graphX = x + offsetX - pan * zoom + width/2f, graphY = y + offsetY, graphW = width - offsetX, graphH = height - offsetY;
float left = (x-graphX)/spacing, right = (x + width - graphX)/spacing;
//int radius = Mathf.ceil(graphW / spacing / 2f);
from = (int)left - 1;
to = (int)right + 1;
if(lastFrom != from || lastTo != to){
rebuild();
}
lastFrom = from;
lastTo = to;
if(!clipBegin(x + offsetX, y + offsetY, graphW, graphH)) return;
int selcol = Rect.contains(x, y, width, height, mouse.x, mouse.y) ? Mathf.round((mouse.x - graphX - (from * spacing)) / spacing) : -1;
if(selcol + from <= -1) selcol = -1;
if(mode == Mode.counts){
for(UnitType type : used.orderedItems()){
Draw.color(color(type));
Draw.alpha(parentAlpha);
Lines.beginLine();
beginLine();
for(int i = 0; i < values.length; i++){
int val = values[i][type.id];
float cx = graphX + i*spacing, cy = 2f + graphY + val * (graphH - 4f) / max;
Lines.linePoint(cx, cy);
float cx = graphX + (i+from) * spacing, cy = graphY + val * graphH / maxY;
linePoint(cx, cy);
}
Lines.endLine();
endLine();
}
}else if(mode == Mode.totals){
Lines.beginLine();
beginLine();
Draw.color(Pal.accent);
for(int i = 0; i < values.length; i++){
@@ -69,13 +146,13 @@ public class WaveGraph extends Table{
sum += values[i][type.id];
}
float cx = graphX + i*spacing, cy = 2f + graphY + sum * (graphH - 4f) / maxTotal;
Lines.linePoint(cx, cy);
float cx = graphX + (i+from) * spacing, cy = graphY + sum * graphH / maxY;
linePoint(cx, cy);
}
Lines.endLine();
endLine();
}else if(mode == Mode.health){
Lines.beginLine();
beginLine();
Draw.color(Pal.health);
for(int i = 0; i < values.length; i++){
@@ -84,37 +161,62 @@ public class WaveGraph extends Table{
sum += (type.health) * values[i][type.id];
}
float cx = graphX + i*spacing, cy = 2f + graphY + sum * (graphH - 4f) / maxHealth;
Lines.linePoint(cx, cy);
float cx = graphX + (i+from) * spacing, cy = graphY + sum * graphH / maxY;
linePoint(cx, cy);
}
Lines.endLine();
endLine();
}
//how many numbers can fit here
float totalMarks = (height - offsetY - getMarginBottom() *2f - 1f) / (lay.height * 2);
int markSpace = Math.max(1, Mathf.ceil(max / totalMarks));
if(selcol >= 0 && selcol < values.length){
Draw.color(1f, 0f, 0f, 0.2f);
Fill.crect((selcol+from) * spacing + graphX - spacing/2f, graphY, spacing, graphH);
Draw.color();
font.getData().setScale(1.5f);
for(UnitType type : used.orderedItems()){
int amount = values[Mathf.clamp(selcol, 0, values.length - 1)][type.id];
if(amount > 0){
countStr.append(type.emoji()).append(" ").append(amount).append("\n");
}
}
float pad = Scl.scl(5f);
font.draw(countStr, (selcol+from) * spacing + graphX - spacing/2f + pad, graphY + graphH - pad);
font.getData().setScale(1f);
}
clipEnd();
//how many numbers can fit here
float totalMarks = Mathf.clamp(maxY, 1, 10);
int markSpace = Math.max(1, Mathf.ceil(maxY / totalMarks));
Draw.color(Color.lightGray);
for(int i = 0; i < max; i += markSpace){
float cy = 2f + y + i * (height - 4f) / max + offsetY, cx = x + offsetX;
//Lines.line(cx, cy, cx + len, cy);
Draw.alpha(0.1f);
for(int i = 0; i < maxY; i += markSpace){
float cy = graphY + i * graphH / maxY, cx = x;
Lines.line(cx, cy, cx + graphW, cy);
lay.setText(font, "" + i);
font.draw("" + i, cx, cy + lay.height/2f - Scl.scl(3f), Align.right);
font.draw("" + i, cx, cy + lay.height / 2f, Align.left);
}
Draw.alpha(1f);
float len = Scl.scl(4f);
font.setColor(Color.lightGray);
for(int i = 0; i < values.length; i++){
float cy = y + fh, cx = x + graphW / (values.length - 1) * i + offsetX;
float cy = y + fh, cx = graphX + spacing * (i + from);
Lines.line(cx, cy, cx, cy + len);
if(i == values.length/2){
font.draw("" + (i + from + 1), cx, cy - 2f, Align.center);
if(cx >= x + offsetX && cx <= x + offsetX + graphW){
Lines.line(cx, cy, cx, cy + len);
}
if(i == selcol){
font.draw("" + (i + from + 1), cx, cy - Scl.scl(2f), Align.center);
}
}
font.setColor(Color.white);
@@ -142,6 +244,28 @@ public class WaveGraph extends Table{
}).growX();
}
private void clampZoom(){
zoom = Mathf.clamp(zoom, 0.5f / Scl.scl(1f), 40f / Scl.scl(1f));
}
private void linePoint(float x, float y){
points.add(x, y);
}
private void beginLine(){
points.clear();
}
private void endLine(){
var items = points.items;
for(int i = 0; i < points.size - 2; i += 2){
Lines.line(items[i], items[i + 1], items[i + 2], items[i + 3], false);
Fill.circle(items[i], items[i + 1], Lines.getStroke()/2f);
}
Fill.circle(items[points.size - 2], items[points.size - 1], Lines.getStroke());
points.clear();
}
public void rebuild(){
values = new int[to - from + 1][Vars.content.units().size];
used.clear();
@@ -164,20 +288,33 @@ public class WaveGraph extends Table{
sum += spawned;
}
maxTotal = Math.max(maxTotal, sum);
maxHealth = Math.max(maxHealth,healthsum);
maxHealth = Math.max(maxHealth, healthsum);
}
used.orderedItems().sort();
ObjectSet<UnitType> usedCopy = new ObjectSet<>(used);
colors.clear();
colors.left();
colors.button("@waves.units.hide", Styles.flatt, () -> {
if(hidden.size == usedCopy.size){
hidden.clear();
}else{
hidden.addAll(usedCopy);
}
used.clear();
used.addAll(usedCopy);
for(UnitType o : hidden) used.remove(o);
}).update(b -> b.setText(hidden.size == usedCopy.size ? "@waves.units.show" : "@waves.units.hide")).height(32f).width(130f);
colors.pane(t -> {
t.left();
for(UnitType type : used){
t.button(b -> {
Color tcolor = color(type).cpy();
b.image().size(32f).update(i -> i.setColor(b.isChecked() ? Tmp.c1.set(tcolor).mul(0.5f) : tcolor)).get().act(1);
b.image(type.icon(Cicon.medium)).padRight(20).update(i -> i.setColor(b.isChecked() ? Color.gray : Color.white)).get().act(1);
b.image(type.uiIcon).size(32f).scaling(Scaling.fit).padRight(20).update(i -> i.setColor(b.isChecked() ? Color.gray : Color.white)).get().act(1);
b.margin(0f);
}, Styles.fullTogglet, () -> {
if(!hidden.add(type)){
@@ -189,7 +326,9 @@ public class WaveGraph extends Table{
for(UnitType o : hidden) used.remove(o);
}).update(b -> b.setChecked(hidden.contains(type)));
}
}).get().setScrollingDisabled(false, true);
}).scrollY(false);
colors.act(0.000001f);
for(UnitType type : hidden){
used.remove(type);
@@ -200,6 +339,23 @@ public class WaveGraph extends Table{
return Tmp.c1.fromHsv(type.id / (float)Vars.content.units().size * 360f, 0.7f, 1f);
}
int nextStep(float value){
int order = 1;
while(order < value){
if(order * 2 > value){
return order * 2;
}
if(order * 5 > value){
return order * 5;
}
if(order * 10 > value){
return order * 10;
}
order *= 10;
}
return order;
}
enum Mode{
counts, totals, health;
+337 -175
View File
@@ -1,13 +1,19 @@
package mindustry.editor;
import arc.*;
import arc.func.*;
import arc.graphics.*;
import arc.input.*;
import arc.math.*;
import arc.math.geom.*;
import arc.scene.event.*;
import arc.scene.style.*;
import arc.scene.ui.*;
import arc.scene.ui.TextField.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import mindustry.*;
import mindustry.content.*;
import mindustry.game.*;
import mindustry.gen.*;
@@ -21,121 +27,121 @@ import static mindustry.Vars.*;
import static mindustry.game.SpawnGroup.*;
public class WaveInfoDialog extends BaseDialog{
private int displayed = 20;
Seq<SpawnGroup> groups = new Seq<>();
private @Nullable SpawnGroup expandedGroup;
private Table table;
private int start = 0;
private UnitType lastType = UnitTypes.dagger;
private float updateTimer, updatePeriod = 1f;
private int search = -1;
private @Nullable UnitType filterType;
private Sort sort = Sort.begin;
private boolean reverseSort = false;
private boolean checkedSpawns;
private WaveGraph graph = new WaveGraph();
public WaveInfoDialog(MapEditor editor){
public WaveInfoDialog(){
super("@waves.title");
shown(this::setup);
shown(() -> {
checkedSpawns = false;
setup();
});
hidden(() -> state.rules.spawns = groups);
addCloseListener();
onResize(this::setup);
addCloseButton();
buttons.button("@waves.edit", () -> {
buttons.button("@waves.edit", Icon.edit, () -> {
BaseDialog dialog = new BaseDialog("@waves.edit");
dialog.addCloseButton();
dialog.setFillParent(false);
dialog.cont.defaults().size(210f, 64f);
dialog.cont.button("@waves.copy", () -> {
ui.showInfoFade("@waves.copied");
Core.app.setClipboardText(maps.writeWaves(groups));
dialog.hide();
}).disabled(b -> groups == null);
dialog.cont.row();
dialog.cont.button("@waves.load", () -> {
try{
groups = maps.readWaves(Core.app.getClipboardText());
dialog.cont.table(Tex.button, t -> {
var style = Styles.cleart;
t.defaults().size(280f, 64f).pad(2f);
t.button("@waves.copy", Icon.copy, style, () -> {
ui.showInfoFade("@waves.copied");
Core.app.setClipboardText(maps.writeWaves(groups));
dialog.hide();
}).disabled(b -> groups == null || groups.isEmpty()).marginLeft(12f).row();
t.button("@waves.load", Icon.download, style, () -> {
try{
groups = maps.readWaves(Core.app.getClipboardText());
buildGroups();
}catch(Exception e){
Log.err(e);
ui.showErrorMessage("@waves.invalid");
}
dialog.hide();
}).disabled(Core.app.getClipboardText() == null || !Core.app.getClipboardText().startsWith("[")).marginLeft(12f).row();
t.button("@clear", Icon.none, style, () -> ui.showConfirm("@confirm", "@settings.clear.confirm", () -> {
groups.clear();
buildGroups();
}catch(Exception e){
e.printStackTrace();
ui.showErrorMessage("@waves.invalid");
}
dialog.hide();
}).disabled(b -> Core.app.getClipboardText() == null || Core.app.getClipboardText().isEmpty());
dialog.cont.row();
dialog.cont.button("@settings.reset", () -> ui.showConfirm("@confirm", "@settings.clear.confirm", () -> {
groups = JsonIO.copy(waves.get());
buildGroups();
dialog.hide();
}));
dialog.hide();
})).marginLeft(12f).row();
t.button("@settings.reset", Icon.refresh, style, () -> ui.showConfirm("@confirm", "@settings.clear.confirm", () -> {
groups = JsonIO.copy(waves.get());
buildGroups();
dialog.hide();
})).marginLeft(12f);
});
dialog.show();
}).size(270f, 64f);
}).size(250f, 64f);
buttons.defaults().width(60f);
buttons.button("<", () -> {}).update(t -> {
if(t.getClickListener().isPressed()){
shift(-1);
}
});
buttons.button(">", () -> {}).update(t -> {
if(t.getClickListener().isPressed()){
shift(1);
}
});
buttons.button("-", () -> {}).update(t -> {
if(t.getClickListener().isPressed()){
view(-1);
}
});
buttons.button("+", () -> {}).update(t -> {
if(t.getClickListener().isPressed()){
view(1);
}
});
if(experimental){
buttons.button("Random", Icon.refresh, () -> {
groups.clear();
groups = Waves.generate(1f / 10f);
updateWaves();
}).width(200f);
}
}
void view(int amount){
updateTimer += Time.delta;
if(updateTimer >= updatePeriod){
displayed += amount;
if(displayed < 5) displayed = 5;
updateTimer = 0f;
updateWaves();
}
}
void shift(int amount){
updateTimer += Time.delta;
if(updateTimer >= updatePeriod){
start += amount;
if(start < 0) start = 0;
updateTimer = 0f;
updateWaves();
}
buttons.button(Core.bundle.get("waves.random"), Icon.refresh, () -> {
groups.clear();
groups = Waves.generate(1f / 10f);
buildGroups();
}).width(200f);
}
void setup(){
groups = JsonIO.copy(state.rules.spawns.isEmpty() ? waves.get() : state.rules.spawns);
if(groups == null) groups = new Seq<>();
cont.clear();
cont.stack(new Table(Tex.clear, main -> {
main.pane(t -> table = t).growX().growY().padRight(8f).get().setScrollingDisabled(true, false);
main.row();
main.button("@add", () -> {
if(groups == null) groups = new Seq<>();
groups.add(new SpawnGroup(lastType));
buildGroups();
}).growX().height(70f);
main.table(s -> {
s.image(Icon.zoom).padRight(8);
s.field(search < 0 ? "" : (search + 1) + "", TextFieldFilter.digitsOnly, text -> {
search = groups.any() ? Strings.parseInt(text, 0) - 1 : -1;
buildGroups();
}).growX().maxTextLength(8).get().setMessageText("@waves.search");
s.button(Icon.units, Styles.emptyi, () -> showUnits(type -> filterType = type, true)).size(46f).tooltip("@waves.filter")
.update(b -> b.getStyle().imageUp = filterType != null ? new TextureRegionDrawable(filterType.uiIcon) : Icon.filter);
}).growX().pad(6f).row();
main.pane(t -> table = t).grow().padRight(8f).scrollX(false).row();
main.table(t -> {
t.button("@add", () -> {
showUnits(type -> groups.add(expandedGroup = new SpawnGroup(type)), false);
buildGroups();
}).growX().height(70f);
t.button(Icon.filter, () -> {
BaseDialog dialog = new BaseDialog("@waves.sort");
dialog.setFillParent(false);
dialog.cont.table(Tex.button, f -> {
for(Sort s : Sort.all){
f.button("@waves.sort." + s, Styles.flatTogglet, () -> {
sort = s;
dialog.hide();
buildGroups();
}).size(150f, 60f).checked(s == sort);
}
}).row();
dialog.cont.check("@waves.sort.reverse", b -> {
reverseSort = b;
buildGroups();
}).padTop(4).checked(reverseSort).padBottom(8f);
dialog.addCloseButton();
dialog.show();
}).size(64f, 70f).padLeft(6f);
}).growX();
}), new Label("@waves.none"){{
visible(() -> groups.isEmpty());
this.touchable = Touchable.disabled;
@@ -154,99 +160,195 @@ public class WaveInfoDialog extends BaseDialog{
table.margin(10f);
if(groups != null){
groups.sort(Structs.comps(Structs.comparingFloat(sort.sort), Structs.comparingFloat(sort.secondary)));
if(reverseSort) groups.reverse();
for(SpawnGroup group : groups){
if(group.effect == StatusEffects.none) group.effect = null;
if((search >= 0 && group.getSpawned(search) <= 0) || (filterType != null && group.type != filterType)) continue;
table.table(Tex.button, t -> {
t.margin(0).defaults().pad(3).padLeft(5f).growX().left();
t.button(b -> {
b.left();
b.image(group.type.icon(Cicon.medium)).size(32f).padRight(3).scaling(Scaling.fit);
b.add(group.type.localizedName).color(Pal.accent);
b.image(group.type.uiIcon).size(32f).padRight(3).scaling(Scaling.fit);
b.add(group.type.localizedName).ellipsis(true).width(110f).left().color(Pal.accent);
b.add().growX();
b.button(Icon.cancel, () -> {
b.label(() -> (group.begin + 1) + "").color(Color.lightGray).minWidth(45f).labelAlign(Align.left).left();
b.button(Icon.copySmall, Styles.emptyi, () -> {
groups.insert(groups.indexOf(group) + 1, expandedGroup = group.copy());
buildGroups();
}).pad(-6).size(46f).tooltip("@editor.copy");
b.button(group.effect != null ?
new TextureRegionDrawable(group.effect.uiIcon) :
Icon.logicSmall,
Styles.emptyi, () -> showEffects(group)).pad(-6).size(46f).scaling(Scaling.fit).tooltip(group.effect != null ? group.effect.localizedName : "@none");
b.button(Icon.unitsSmall, Styles.emptyi, () -> showUnits(type -> group.type = type, false)).pad(-6).size(46f).tooltip("@stat.unittype");
b.button(Icon.cancel, Styles.emptyi, () -> {
groups.remove(group);
if(expandedGroup == group) expandedGroup = null;
table.getCell(t).pad(0f);
t.remove();
updateWaves();
}).pad(-6).size(46f).padRight(-12f);
}, () -> showUpdate(group)).height(46f).pad(-6f).padBottom(0f);
buildGroups();
}).pad(-6).size(46f).padRight(-12f).tooltip("@waves.remove");
b.clicked(KeyCode.mouseMiddle, () -> {
groups.insert(groups.indexOf(group) + 1, expandedGroup = group.copy());
buildGroups();
});
}, () -> {
expandedGroup = expandedGroup == group ? null : group;
buildGroups();
}).height(46f).pad(-6f).padBottom(0f).row();
t.row();
t.table(spawns -> {
spawns.field("" + (group.begin + 1), TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.begin = Strings.parseInt(text) - 1;
updateWaves();
}
}).width(100f);
spawns.add("@waves.to").padLeft(4).padRight(4);
spawns.field(group.end == never ? "" : (group.end + 1) + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.end = Strings.parseInt(text) - 1;
updateWaves();
}else if(text.isEmpty()){
group.end = never;
updateWaves();
}
}).width(100f).get().setMessageText(Core.bundle.get("waves.never"));
});
t.row();
t.table(p -> {
p.add("@waves.every").padRight(4);
p.field(group.spacing + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text) && Strings.parseInt(text) > 0){
group.spacing = Strings.parseInt(text);
updateWaves();
}
}).width(100f);
p.add("@waves.waves").padLeft(4);
});
if(expandedGroup == group){
t.table(spawns -> {
spawns.field("" + (group.begin + 1), TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.begin = Strings.parseInt(text) - 1;
updateWaves();
}
}).width(100f);
spawns.add("@waves.to").padLeft(4).padRight(4);
spawns.field(group.end == never ? "" : (group.end + 1) + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.end = Strings.parseInt(text) - 1;
updateWaves();
}else if(text.isEmpty()){
group.end = never;
updateWaves();
}
}).width(100f).get().setMessageText("");
}).row();
t.row();
t.table(a -> {
a.field(group.unitAmount + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.unitAmount = Strings.parseInt(text);
updateWaves();
}
}).width(80f);
t.table(p -> {
p.add("@waves.every").padRight(4);
p.field(group.spacing + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text) && Strings.parseInt(text) > 0){
group.spacing = Strings.parseInt(text);
updateWaves();
}
}).width(100f);
p.add("@waves.waves").padLeft(4);
}).row();
a.add(" + ");
a.field(Strings.fixed(Math.max((Mathf.zero(group.unitScaling) ? 0 : 1f / group.unitScaling), 0), 2), TextFieldFilter.floatsOnly, text -> {
if(Strings.canParsePositiveFloat(text)){
group.unitScaling = 1f / Strings.parseFloat(text);
updateWaves();
}
}).width(80f);
a.add("@waves.perspawn").padLeft(4);
});
t.row();
t.table(a -> {
a.field((int)group.shields + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.shields = Strings.parseInt(text);
updateWaves();
}
}).width(80f);
t.table(a -> {
a.field(group.unitAmount + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.unitAmount = Strings.parseInt(text);
updateWaves();
}
}).width(80f);
a.add(" + ");
a.field((int)group.shieldScaling + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.shieldScaling = Strings.parseInt(text);
updateWaves();
}
}).width(80f);
a.add("@waves.shields").padLeft(4);
});
a.add(" + ");
a.field(Strings.fixed(Math.max((Mathf.zero(group.unitScaling) ? 0 : 1f / group.unitScaling), 0), 2), TextFieldFilter.floatsOnly, text -> {
if(Strings.canParsePositiveFloat(text)){
group.unitScaling = 1f / Strings.parseFloat(text);
updateWaves();
}
}).width(80f);
a.add("@waves.perspawn").padLeft(4);
}).row();
t.row();
t.check("@waves.guardian", b -> group.effect = (b ? StatusEffects.boss : null)).padTop(4).update(b -> b.setChecked(group.effect == StatusEffects.boss)).padBottom(8f);
t.table(a -> {
a.field(group.max + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.max = Strings.parseInt(text);
updateWaves();
}
}).width(80f);
a.add("@waves.max").padLeft(5);
}).row();
t.table(a -> {
a.field((int)group.shields + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.shields = Strings.parseInt(text);
updateWaves();
}
}).width(80f);
a.add(" + ");
a.field((int)group.shieldScaling + "", TextFieldFilter.digitsOnly, text -> {
if(Strings.canParsePositiveInt(text)){
group.shieldScaling = Strings.parseInt(text);
updateWaves();
}
}).width(80f);
a.add("@waves.shields").padLeft(4);
}).row();
t.check("@waves.guardian", b -> {
group.effect = (b ? StatusEffects.boss : null);
buildGroups();
}).padTop(4).update(b -> b.setChecked(group.effect == StatusEffects.boss)).padBottom(8f).row();
t.table(a -> {
a.add("@waves.spawn").padRight(8);
a.button("", () -> {
if(!checkedSpawns){
//recalculate waves when changed
Vars.spawner.reset();
checkedSpawns = true;
}
BaseDialog dialog = new BaseDialog("@waves.spawn.select");
dialog.cont.pane(p -> {
p.background(Tex.button).margin(10f);
int i = 0;
int cols = 4;
int max = 20;
if(spawner.getSpawns().size >= max){
p.add("[lightgray](first " + max + ")").colspan(cols).padBottom(4).row();
}
for(var spawn : spawner.getSpawns()){
p.button(spawn.x + ", " + spawn.y, Styles.flatTogglet, () -> {
group.spawn = Point2.pack(spawn.x, spawn.y);
dialog.hide();
}).size(110f, 45f).checked(spawn.pos() == group.spawn);
if(++i % cols == 0){
p.row();
}
//only display first 20 spawns, you don't need to see more.
if(i >= 20){
break;
}
}
if(spawner.getSpawns().isEmpty()){
p.add("@waves.spawn.none");
}else{
p.button("@waves.spawn.all", Styles.flatTogglet, () -> {
group.spawn = -1;
dialog.hide();
}).size(110f, 45f).checked(-1 == group.spawn);
}
}).grow();
dialog.setFillParent(false);
dialog.addCloseButton();
dialog.show();
}).width(160f).height(36f).get().getLabel().setText(() -> group.spawn == -1 ? "@waves.spawn.all" : Point2.x(group.spawn) + ", " + Point2.y(group.spawn));
}).padBottom(8f).row();
}
}).width(340f).pad(8);
table.row();
}
if(table.getChildren().isEmpty() && groups.any()){
table.add("@none.found");
}
}else{
table.add("@editor.default");
}
@@ -254,33 +356,93 @@ public class WaveInfoDialog extends BaseDialog{
updateWaves();
}
void showUpdate(SpawnGroup group){
BaseDialog dialog = new BaseDialog("");
dialog.setFillParent(true);
void showUnits(Cons<UnitType> cons, boolean reset){
BaseDialog dialog = new BaseDialog(reset ? "@waves.filter" : "");
dialog.cont.pane(p -> {
int i = 0;
p.defaults().pad(2).fillX();
if(reset){
p.button(t -> {
t.left();
t.image(Icon.none).size(8 * 4).scaling(Scaling.fit).padRight(2f);
t.add("@settings.resetKey");
}, () -> {
cons.get(null);
dialog.hide();
buildGroups();
}).margin(12f);
}
int i = reset ? 1 : 0;
for(UnitType type : content.units()){
if(type.isHidden()) continue;
p.button(t -> {
t.left();
t.image(type.icon(Cicon.medium)).size(8 * 4).scaling(Scaling.fit).padRight(2f);
t.image(type.uiIcon).size(8 * 4).scaling(Scaling.fit).padRight(2f);
t.add(type.localizedName);
}, () -> {
lastType = type;
group.type = type;
cons.get(type);
dialog.hide();
buildGroups();
}).pad(2).margin(12f).fillX();
}).margin(12f);
if(++i % 3 == 0) p.row();
}
});
}).growX().scrollX(false);
dialog.addCloseButton();
dialog.show();
}
void showEffects(SpawnGroup group){
BaseDialog dialog = new BaseDialog("");
dialog.cont.pane(p -> {
p.defaults().pad(2).fillX();
p.button(t -> {
t.left();
t.image(Icon.none).size(8 * 4).scaling(Scaling.fit).padRight(2f);
t.add("@settings.resetKey");
}, () -> {
group.effect = null;
dialog.hide();
buildGroups();
}).margin(12f);
int i = 1;
for(StatusEffect effect : content.statusEffects()){
if(effect.isHidden() || effect.reactive) continue;
p.button(t -> {
t.left();
t.image(effect.uiIcon).size(8 * 4).scaling(Scaling.fit).padRight(2f);
t.add(effect.localizedName);
}, () -> {
group.effect = effect;
dialog.hide();
buildGroups();
}).margin(12f);
if(++i % 3 == 0) p.row();
}
}).growX().scrollX(false);
dialog.addCloseButton();
dialog.show();
}
enum Sort{
begin(g -> g.begin, g -> g.type.id),
health(g -> g.type.health),
type(g -> g.type.id);
static final Sort[] all = values();
final Floatf<SpawnGroup> sort, secondary;
Sort(Floatf<SpawnGroup> sort){
this(sort, g -> g.begin);
}
Sort(Floatf<SpawnGroup> sort, Floatf<SpawnGroup> secondary){
this.sort = sort;
this.secondary = secondary;
}
}
void updateWaves(){
graph.groups = groups;
graph.from = start;
graph.to = start + displayed;
graph.rebuild();
}
}
}
+375 -155
View File
@@ -2,11 +2,12 @@ package mindustry.entities;
import arc.*;
import arc.func.*;
import arc.graphics.*;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.annotations.Annotations.*;
import arc.util.pooling.*;
import mindustry.content.*;
import mindustry.core.*;
import mindustry.game.EventType.*;
@@ -15,33 +16,81 @@ import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.type.*;
import mindustry.world.*;
import mindustry.world.blocks.*;
import mindustry.world.meta.*;
import static mindustry.Vars.*;
/** Utility class for damaging in an area. */
public class Damage{
private static final UnitDamageEvent bulletDamageEvent = new UnitDamageEvent();
private static final Rect rect = new Rect();
private static final Rect hitrect = new Rect();
private static final Vec2 vec = new Vec2(), seg1 = new Vec2(), seg2 = new Vec2();
private static final IntSet collidedBlocks = new IntSet();
private static final IntFloatMap damages = new IntFloatMap();
private static final Seq<Collided> collided = new Seq<>();
private static final Pool<Collided> collidePool = Pools.get(Collided.class, Collided::new);
private static final Seq<Building> builds = new Seq<>();
private static final FloatSeq distances = new FloatSeq();
private static Tile furthest;
private static Rect rect = new Rect();
private static Rect hitrect = new Rect();
private static Vec2 tr = new Vec2(), seg1 = new Vec2(), seg2 = new Vec2();
private static Seq<Unit> units = new Seq<>();
private static GridBits bits = new GridBits(30, 30);
private static IntQueue propagation = new IntQueue();
private static IntSet collidedBlocks = new IntSet();
private static float maxDst = 0f;
private static Building tmpBuilding;
private static Unit tmpUnit;
public static void applySuppression(Team team, float x, float y, float range, float reload, float maxDelay, float applyParticleChance, @Nullable Position source){
applySuppression(team, x, y, range, reload, maxDelay, applyParticleChance, source, Pal.sapBullet);
}
public static void applySuppression(Team team, float x, float y, float range, float reload, float maxDelay, float applyParticleChance, @Nullable Position source, Color effectColor){
builds.clear();
indexer.eachBlock(null, x, y, range, build -> build.team != team, build -> {
float prev = build.healSuppressionTime;
build.applyHealSuppression(reload + 1f, effectColor);
//TODO maybe should be block field instead of instanceof check
if(build.wasRecentlyHealed(60f * 12f) || build.block.suppressable){
//add prev check so ability spam doesn't lead to particle spam (essentially, recently suppressed blocks don't get new particles)
if(!headless && prev - Time.time <= reload/2f){
builds.add(build);
}
}
});
//to prevent particle spam, the amount of particles is to remain constant (scales with number of buildings)
float scaledChance = applyParticleChance / builds.size;
for(var build : builds){
if(Mathf.chance(scaledChance)){
Time.run(Mathf.random(maxDelay), () -> {
Fx.regenSuppressSeek.at(build.x + Mathf.range(build.block.size * tilesize / 2f), build.y + Mathf.range(build.block.size * tilesize / 2f), 0f, effectColor, source);
});
}
}
}
/** Creates a dynamic explosion based on specified parameters. */
public static void dynamicExplosion(float x, float y, float flammability, float explosiveness, float power, float radius, boolean damage){
dynamicExplosion(x, y, flammability, explosiveness, power, radius, damage, true, null);
dynamicExplosion(x, y, flammability, explosiveness, power, radius, damage, true, null, Fx.dynamicExplosion);
}
/** Creates a dynamic explosion based on specified parameters. */
public static void dynamicExplosion(float x, float y, float flammability, float explosiveness, float power, float radius, boolean damage, Effect explosionFx){
dynamicExplosion(x, y, flammability, explosiveness, power, radius, damage, true, null, explosionFx);
}
/** Creates a dynamic explosion based on specified parameters. */
public static void dynamicExplosion(float x, float y, float flammability, float explosiveness, float power, float radius, boolean damage, boolean fire, @Nullable Team ignoreTeam){
dynamicExplosion(x, y, flammability, explosiveness, power, radius, damage, fire, ignoreTeam, Fx.dynamicExplosion);
}
/** Creates a dynamic explosion based on specified parameters. */
public static void dynamicExplosion(float x, float y, float flammability, float explosiveness, float power, float radius, boolean damage, boolean fire, @Nullable Team ignoreTeam, Effect explosionFx){
if(damage){
for(int i = 0; i < Mathf.clamp(power / 20, 0, 6); i++){
int branches = 5 + Mathf.clamp((int)(power / 30), 1, 20);
Time.run(i * 2f + Mathf.random(4f), () -> Lightning.create(Team.derelict, Pal.power, 3, x, y, Mathf.random(360f), branches + Mathf.range(2)));
for(int i = 0; i < Mathf.clamp(power / 700, 0, 8); i++){
int length = 5 + Mathf.clamp((int)(Mathf.pow(power, 0.98f) / 500), 1, 18);
Time.run(i * 0.8f + Mathf.random(4f), () -> Lightning.create(Team.derelict, Pal.power, 3 + Mathf.pow(power, 0.35f), x, y, Mathf.random(360f), length + Mathf.range(2)));
}
if(fire){
@@ -50,12 +99,17 @@ public class Damage{
}
}
int waves = Mathf.clamp((int)(explosiveness / 4), 0, 30);
int waves = explosiveness <= 2 ? 0 : Mathf.clamp((int)(explosiveness / 11), 1, 25);
float damagePerWave = explosiveness / 2f;
for(int i = 0; i < waves; i++){
var shields = ignoreTeam == null ? null : indexer.getEnemy(ignoreTeam, BlockFlag.shield);
int f = i;
Time.run(i * 2f, () -> {
Damage.damage(ignoreTeam, x, y, Mathf.clamp(radius + explosiveness, 0, 50f) * ((f + 1f) / waves), explosiveness / 2f, false);
if(shields == null || shields.isEmpty() || !shields.contains(b -> b instanceof ExplosionShield s && s.absorbExplosion(x, y, damagePerWave))){
damage(ignoreTeam, x, y, Mathf.clamp(radius + explosiveness, 0, 50f) * ((f + 1f) / waves), damagePerWave, false);
}
Fx.blockExplosionSmoke.at(x + Mathf.range(radius), y + Mathf.range(radius));
});
}
@@ -71,7 +125,7 @@ public class Damage{
float shake = Math.min(explosiveness / 4f + 3f, 9f);
Effect.shake(shake, shake, x, y);
Fx.dynamicExplosion.at(x, y, radius / 8f);
explosionFx.at(x, y, radius / 8f);
}
public static void createIncend(float x, float y, float range, int amount){
@@ -85,22 +139,86 @@ public class Damage{
}
}
public static @Nullable Building findAbsorber(Team team, float x1, float y1, float x2, float y2){
tmpBuilding = null;
boolean found = World.raycast(World.toTile(x1), World.toTile(y1), World.toTile(x2), World.toTile(y2),
(x, y) -> (tmpBuilding = world.build(x, y)) != null && tmpBuilding.team != team && tmpBuilding.block.absorbLasers);
return found ? tmpBuilding : null;
}
public static float findLength(Bullet b, float length, boolean laser, int pierceCap){
if(pierceCap > 0){
length = findPierceLength(b, pierceCap, laser, length);
}else if(laser){
length = findLaserLength(b, length);
}
return length;
}
public static float findLaserLength(Bullet b, float length){
Tmp.v1.trns(b.rotation(), length);
vec.trnsExact(b.rotation(), length);
furthest = null;
boolean found = world.raycast(b.tileX(), b.tileY(), World.toTile(b.x + Tmp.v1.x), World.toTile(b.y + Tmp.v1.y),
(x, y) -> (furthest = world.tile(x, y)) != null && furthest.team() != b.team && furthest.block().absorbLasers);
boolean found = World.raycast(b.tileX(), b.tileY(), World.toTile(b.x + vec.x), World.toTile(b.y + vec.y),
(x, y) -> (furthest = world.tile(x, y)) != null && furthest.team() != b.team && (furthest.build != null && furthest.build.absorbLasers()));
return found && furthest != null ? Math.max(6f, b.dst(furthest.worldx(), furthest.worldy())) : length;
}
/** Collides a bullet with blocks in a laser, taking into account absorption blocks. Resulting length is stored in the bullet's fdata. */
public static float collideLaser(Bullet b, float length, boolean large){
float resultLength = findLaserLength(b, length);
public static float findPierceLength(Bullet b, int pierceCap, float length){
return findPierceLength(b, pierceCap, b.type.laserAbsorb, length);
}
collideLine(b, b.team, b.type.hitEffect, b.x, b.y, b.rotation(), resultLength, large);
public static float findPierceLength(Bullet b, int pierceCap, boolean laser, float length){
vec.trnsExact(b.rotation(), length);
rect.setPosition(b.x, b.y).setSize(vec.x, vec.y).normalize().grow(3f);
maxDst = Float.POSITIVE_INFINITY;
distances.clear();
if(b.type.collidesGround && b.type.collidesTiles){
World.raycast(b.tileX(), b.tileY(), World.toTile(b.x + vec.x), World.toTile(b.y + vec.y), (x, y) -> {
//add distance to list so it can be processed
var build = world.build(x, y);
if(build != null && build.team != b.team && build.collide(b) && b.checkUnderBuild(build, x * tilesize, y * tilesize)){
distances.add(b.dst(build));
if(laser && build.absorbLasers()){
maxDst = Math.min(maxDst, b.dst(build));
return true;
}
}
return false;
});
}
Units.nearbyEnemies(b.team, rect, u -> {
u.hitbox(hitrect);
if(u.checkTarget(b.type.collidesAir, b.type.collidesGround) && u.hittable() && Intersector.intersectSegmentRectangle(b.x, b.y, b.x + vec.x, b.y + vec.y, hitrect)){
distances.add(u.dst(b));
}
});
distances.sort();
//return either the length when not enough things were pierced,
//or the last pierced object if there were enough blockages
return Math.min(distances.size < pierceCap || pierceCap < 0 ? length : Math.max(6f, distances.get(pierceCap - 1)), maxDst);
}
/** Collides a bullet with blocks in a laser, taking into account absorption blocks. Resulting length is stored in the bullet's fdata. */
public static float collideLaser(Bullet b, float length, boolean large, boolean laser, int pierceCap){
float resultLength = findPierceLength(b, pierceCap, laser, length);
collideLine(b, b.team, b.type.hitEffect, b.x, b.y, b.rotation(), resultLength, large, laser, pierceCap);
b.fdata = resultLength;
@@ -116,88 +234,128 @@ public class Damage{
* Only enemies of the specified team are damaged.
*/
public static void collideLine(Bullet hitter, Team team, Effect effect, float x, float y, float angle, float length, boolean large){
length = findLaserLength(hitter, length);
collideLine(hitter, team, effect, x, y, angle, length, large, true);
}
/**
* Damages entities in a line.
* Only enemies of the specified team are damaged.
*/
public static void collideLine(Bullet hitter, Team team, Effect effect, float x, float y, float angle, float length, boolean large, boolean laser){
collideLine(hitter, team, effect, x, y, angle, length, large, laser, -1);
}
/**
* Damages entities in a line.
* Only enemies of the specified team are damaged.
*/
public static void collideLine(Bullet hitter, Team team, Effect effect, float x, float y, float angle, float length, boolean large, boolean laser, int pierceCap){
length = findLength(hitter, length, laser, pierceCap);
hitter.fdata = length;
collidedBlocks.clear();
tr.trns(angle, length);
vec.trnsExact(angle, length);
Intc2 collider = (cx, cy) -> {
Building tile = world.build(cx, cy);
boolean collide = tile != null && collidedBlocks.add(tile.pos());
if(hitter.damage > 0){
float health = !collide ? 0 : tile.health;
if(collide && tile.team != team && tile.collide(hitter)){
tile.collision(hitter);
hitter.type.hit(hitter, tile.x, tile.y);
}
//try to heal the tile
if(collide && hitter.type.testCollision(hitter, tile)){
hitter.type.hitTile(hitter, tile, health, false);
}
}
};
if(hitter.type.collidesGround){
if(hitter.type.collidesGround && hitter.type.collidesTiles){
seg1.set(x, y);
seg2.set(seg1).add(tr);
world.raycastEachWorld(x, y, seg2.x, seg2.y, (cx, cy) -> {
collider.get(cx, cy);
seg2.set(seg1).add(vec);
World.raycastEachWorld(x, y, seg2.x, seg2.y, (cx, cy) -> {
Building tile = world.build(cx, cy);
boolean collide = tile != null && tile.collide(hitter) && hitter.checkUnderBuild(tile, cx * tilesize, cy * tilesize)
&& ((tile.team != team && tile.collide(hitter)) || hitter.type.testCollision(hitter, tile)) && collidedBlocks.add(tile.pos());
if(collide){
collided.add(collidePool.obtain().set(cx * tilesize, cy * tilesize, tile));
for(Point2 p : Geometry.d4){
Tile other = world.tile(p.x + cx, p.y + cy);
if(other != null && (large || Intersector.intersectSegmentRectangle(seg1, seg2, other.getBounds(Tmp.r1)))){
collider.get(cx + p.x, cy + p.y);
for(Point2 p : Geometry.d4){
Tile other = world.tile(p.x + cx, p.y + cy);
if(other != null && (large || Intersector.intersectSegmentRectangle(seg1, seg2, other.getBounds(Tmp.r1)))){
Building build = other.build;
if(build != null && hitter.checkUnderBuild(build, cx * tilesize, cy * tilesize) && collidedBlocks.add(build.pos())){
collided.add(collidePool.obtain().set((p.x + cx * tilesize), (p.y + cy) * tilesize, build));
}
}
}
}
return false;
});
}
rect.setPosition(x, y).setSize(tr.x, tr.y);
float x2 = tr.x + x, y2 = tr.y + y;
if(rect.width < 0){
rect.x += rect.width;
rect.width *= -1;
}
if(rect.height < 0){
rect.y += rect.height;
rect.height *= -1;
}
float expand = 3f;
rect.y -= expand;
rect.x -= expand;
rect.width += expand * 2;
rect.height += expand * 2;
Cons<Unit> cons = e -> {
e.hitbox(hitrect);
Vec2 vec = Geometry.raycastRect(x, y, x2, y2, hitrect.grow(expand * 2));
if(vec != null && hitter.damage > 0){
effect.at(vec.x, vec.y);
e.collision(hitter, vec.x, vec.y);
hitter.collision(e, vec.x, vec.y);
}
};
units.clear();
rect.setPosition(x, y).setSize(vec.x, vec.y).normalize().grow(expand * 2f);
float x2 = vec.x + x, y2 = vec.y + y;
Units.nearbyEnemies(team, rect, u -> {
if(u.checkTarget(hitter.type.collidesAir, hitter.type.collidesGround)){
units.add(u);
if(u.checkTarget(hitter.type.collidesAir, hitter.type.collidesGround) && u.hittable()){
u.hitbox(hitrect);
Vec2 vec = Geometry.raycastRect(x, y, x2, y2, hitrect.grow(expand * 2));
if(vec != null){
collided.add(collidePool.obtain().set(vec.x, vec.y, u));
}
}
});
units.sort(u -> u.dst2(hitter));
units.each(cons);
int[] collideCount = {0};
collided.sort(c -> hitter.dst2(c.x, c.y));
collided.each(c -> {
if(hitter.damage > 0 && (pierceCap <= 0 || collideCount[0] < pierceCap)){
if(c.target instanceof Unit u){
u.collision(hitter, c.x, c.y);
hitter.collision(u, c.x, c.y);
collideCount[0]++;
}else if(c.target instanceof Building tile){
float health = tile.health;
if(tile.team != team && tile.collide(hitter)){
tile.collision(hitter);
hitter.type.hit(hitter, c.x, c.y);
collideCount[0]++;
}
//try to heal the tile
if(hitter.type.testCollision(hitter, tile)){
hitter.type.hitTile(hitter, tile, c.x, c.y, health, false);
}
}
}
});
collidePool.freeAll(collided);
collided.clear();
}
/**
* Damages entities on a point.
* Only enemies of the specified team are damaged.
*/
public static void collidePoint(Bullet hitter, Team team, Effect effect, float x, float y){
if(hitter.type.collidesGround){
Building build = world.build(World.toTile(x), World.toTile(y));
if(build != null && hitter.damage > 0){
float health = build.health;
if(build.team != team && build.collide(hitter)){
build.collision(hitter);
hitter.type.hit(hitter, x, y);
}
//try to heal the tile
if(hitter.type.testCollision(hitter, build)){
hitter.type.hitTile(hitter, build, x, y, health, false);
}
}
}
Units.nearbyEnemies(team, rect.setCentered(x, y, 1f), u -> {
if(u.checkTarget(hitter.type.collidesAir, hitter.type.collidesGround) && u.hittable()){
u.collision(hitter, x, y);
hitter.collision(u, x, y);
}
});
}
/**
@@ -205,12 +363,12 @@ public class Damage{
* @return the first encountered object.
*/
public static Healthc linecast(Bullet hitter, float x, float y, float angle, float length){
tr.trns(angle, length);
vec.trns(angle, length);
tmpBuilding = null;
if(hitter.type.collidesGround){
tmpBuilding = null;
world.raycastEachWorld(x, y, x + tr.x, y + tr.y, (cx, cy) -> {
World.raycastEachWorld(x, y, x + vec.x, y + vec.y, (cx, cy) -> {
Building tile = world.build(cx, cy);
if(tile != null && tile.team != hitter.team){
tmpBuilding = tile;
@@ -218,12 +376,10 @@ public class Damage{
}
return false;
});
if(tmpBuilding != null) return tmpBuilding;
}
rect.setPosition(x, y).setSize(tr.x, tr.y);
float x2 = tr.x + x, y2 = tr.y + y;
rect.setPosition(x, y).setSize(vec.x, vec.y);
float x2 = vec.x + x, y2 = vec.y + y;
if(rect.width < 0){
rect.x += rect.width;
@@ -244,8 +400,8 @@ public class Damage{
tmpUnit = null;
Cons<Unit> cons = e -> {
if((tmpUnit != null && e.dst2(x, y) > tmpUnit.dst2(x, y)) || !e.checkTarget(hitter.type.collidesAir, hitter.type.collidesGround)) return;
Units.nearbyEnemies(hitter.team, rect, e -> {
if((tmpUnit != null && e.dst2(x, y) > tmpUnit.dst2(x, y)) || !e.checkTarget(hitter.type.collidesAir, hitter.type.collidesGround) || !e.targetable(hitter.team)) return;
e.hitbox(hitrect);
Rect other = hitrect;
@@ -259,9 +415,15 @@ public class Damage{
if(vec != null){
tmpUnit = e;
}
};
});
Units.nearbyEnemies(hitter.team, rect, cons);
if(tmpBuilding != null && tmpUnit != null){
if(Mathf.dst2(x, y, tmpBuilding.getX(), tmpBuilding.getY()) <= Mathf.dst2(x, y, tmpUnit.getX(), tmpUnit.getY())){
return tmpBuilding;
}
}else if(tmpBuilding != null){
return tmpBuilding;
}
return tmpUnit;
}
@@ -269,7 +431,7 @@ public class Damage{
/** Damages all entities and blocks in a radius that are enemies of the team. */
public static void damageUnits(Team team, float x, float y, float size, float damage, Boolf<Unit> predicate, Cons<Unit> acceptor){
Cons<Unit> cons = entity -> {
if(!predicate.get(entity)) return;
if(!predicate.get(entity) || !entity.hittable()) return;
entity.hitbox(hitrect);
if(!hitrect.overlaps(rect)){
@@ -305,7 +467,7 @@ public class Damage{
/** Applies a status effect to all enemy units in a range. */
public static void status(Team team, float x, float y, float radius, StatusEffect effect, float duration, boolean air, boolean ground){
Cons<Unit> cons = entity -> {
if(entity.team == team || !entity.within(x, y, radius) || (entity.isFlying() && !air) || (entity.isGrounded() && !ground)){
if(entity.team == team || !entity.checkTarget(air, ground) || !entity.hittable() || !entity.within(x, y, radius)){
return;
}
@@ -327,17 +489,34 @@ public class Damage{
/** Damages all entities and blocks in a radius that are enemies of the team. */
public static void damage(Team team, float x, float y, float radius, float damage, boolean complete, boolean air, boolean ground){
Cons<Unit> cons = entity -> {
if(entity.team == team || !entity.within(x, y, radius) || (entity.isFlying() && !air) || (entity.isGrounded() && !ground)){
damage(team, x, y, radius, damage, complete, air, ground, false, null);
}
/** Damages all entities and blocks in a radius that are enemies of the team. */
public static void damage(Team team, float x, float y, float radius, float damage, boolean complete, boolean air, boolean ground, boolean scaled, @Nullable Bullet source){
Cons<Unit> cons = unit -> {
if(unit.team == team || !unit.checkTarget(air, ground) || !unit.hittable() || !unit.within(x, y, radius + (scaled ? unit.hitSize / 2f : 0f))){
return;
}
float amount = calculateDamage(x, y, entity.getX(), entity.getY(), radius, damage);
entity.damage(amount);
//TODO better velocity displacement
float dst = tr.set(entity.getX() - x, entity.getY() - y).len();
entity.vel.add(tr.setLength((1f - dst / radius) * 2f / entity.mass()));
if(complete && damage >= 9999999f && entity.isPlayer()){
boolean dead = unit.dead;
float amount = calculateDamage(scaled ? Math.max(0, unit.dst(x, y) - unit.type.hitSize/2) : unit.dst(x, y), radius, damage);
unit.damage(amount);
if(source != null){
Events.fire(bulletDamageEvent.set(unit, source));
unit.controller().hit(source);
if(!dead && unit.dead){
Events.fire(new UnitBulletDestroyEvent(unit, source));
}
}
//TODO better velocity displacement
float dst = vec.set(unit.x - x, unit.y - y).len();
unit.vel.add(vec.setLength((1f - dst / radius) * 2f / unit.mass()));
if(complete && damage >= 9999999f && unit.isPlayer()){
Events.fire(Trigger.exclusionDeath);
}
};
@@ -351,89 +530,130 @@ public class Damage{
if(ground){
if(!complete){
int trad = (int)(radius / tilesize);
Tile tile = world.tileWorld(x, y);
if(tile != null){
tileDamage(team, tile.x, tile.y, trad, damage);
}
tileDamage(team, World.toTile(x), World.toTile(y), radius / tilesize, damage * (source == null ? 1f : source.type.buildingDamageMultiplier), source);
}else{
completeDamage(team, x, y, radius, damage);
}
}
}
public static void tileDamage(Team team, int startx, int starty, int baseRadius, float baseDamage){
//tile damage is posted, so that destroying a block that causes a chain explosion will run in the next frame
//this prevents recursive damage calls from messing up temporary variables
public static void tileDamage(Team team, int x, int y, float baseRadius, float damage){
tileDamage(team, x, y, baseRadius, damage, null);
}
public static void tileDamage(Team team, int x, int y, float baseRadius, float damage, @Nullable Bullet source){
Core.app.post(() -> {
var in = world.build(x, y);
//spawned inside a multiblock. this means that damage needs to be dealt directly.
//why? because otherwise the building would absorb everything in one cell, which means much less damage than a nearby explosion.
//this needs to be compensated
if(in != null && in.team != team && in.block.size > 1 && in.health > damage){
//deal the damage of an entire side, to be equivalent with maximum 'standard' damage
in.damage(team, damage * Math.min((in.block.size), baseRadius * 0.4f));
//no need to continue with the explosion
return;
}
bits.clear();
propagation.clear();
int bitOffset = bits.width() / 2;
//cap radius to prevent lag
float radius = Math.min(baseRadius, 100), rad2 = radius * radius;
int rays = Mathf.ceil(radius * 2 * Mathf.pi);
double spacing = Math.PI * 2.0 / rays;
damages.clear();
propagation.addFirst(PropCell.get((byte)0, (byte)0, (short)baseDamage));
//clamp radius to fit bits
int radius = Math.min(baseRadius, bits.width() / 2);
//raycast from each angle
for(int i = 0; i <= rays; i++){
float dealt = 0f;
int startX = x;
int startY = y;
int endX = x + (int)(Math.cos(spacing * i) * radius), endY = y + (int)(Math.sin(spacing * i) * radius);
while(!propagation.isEmpty()){
int prop = propagation.removeLast();
int x = PropCell.x(prop);
int y = PropCell.y(prop);
int damage = PropCell.damage(prop);
//manhattan distance used for calculating falloff, results in a diamond pattern
int dst = Math.abs(x) + Math.abs(y);
int xDist = Math.abs(endX - startX);
int yDist = -Math.abs(endY - startY);
int xStep = (startX < endX ? +1 : -1);
int yStep = (startY < endY ? +1 : -1);
int error = xDist + yDist;
int scaledDamage = (int)(damage * (1f - (float)dst / radius));
while(startX != endX || startY != endY){
var build = world.build(startX, startY);
if(build != null && build.team != team){
//damage dealt at circle edge
float edgeScale = 0.6f;
float mult = (1f-(Mathf.dst2(startX, startY, x, y) / rad2) + edgeScale) / (1f + edgeScale);
float next = damage * mult - dealt;
//register damage dealt
int p = Point2.pack(startX, startY);
damages.put(p, Math.max(damages.get(p), next));
//register as hit
dealt += build.health;
bits.set(bitOffset + x, bitOffset + y);
Tile tile = world.tile(startx + x, starty + y);
if(next - dealt <= 0){
break;
}
}
if(scaledDamage <= 0 || tile == null) continue;
//apply damage to entity if needed
if(tile.build != null && tile.build.team != team){
int health = (int)(tile.build.health / (tile.block().size * tile.block().size));
if(tile.build.health > 0){
tile.build.damage(scaledDamage);
scaledDamage -= health;
if(scaledDamage <= 0) continue;
if(2 * error - yDist > xDist - 2 * error){
error += yDist;
startX += xStep;
}else{
error += xDist;
startY += yStep;
}
}
}
for(Point2 p : Geometry.d4){
if(!bits.get(bitOffset + x + p.x, bitOffset + y + p.y)){
propagation.addFirst(PropCell.get((byte)(x + p.x), (byte)(y + p.y), (short)scaledDamage));
//apply damage
for(var e : damages){
int cx = Point2.x(e.key), cy = Point2.y(e.key);
var build = world.build(cx, cy);
if(build != null){
if(source != null){
build.damage(source, team, e.value);
}else{
build.damage(team, e.value);
}
}
}
});
}
private static void completeDamage(Team team, float x, float y, float radius, float damage){
int trad = (int)(radius / tilesize);
for(int dx = -trad; dx <= trad; dx++){
for(int dy = -trad; dy <= trad; dy++){
Tile tile = world.tile(Math.round(x / tilesize) + dx, Math.round(y / tilesize) + dy);
if(tile != null && tile.build != null && (team == null ||team.isEnemy(tile.team())) && Mathf.dst(dx, dy) <= trad){
tile.build.damage(damage);
if(tile != null && tile.build != null && (team == null || team != tile.team()) && dx*dx + dy*dy <= trad*trad){
tile.build.damage(team, damage);
}
}
}
}
private static float calculateDamage(float x, float y, float tx, float ty, float radius, float damage){
float dist = Mathf.dst(x, y, tx, ty);
private static float calculateDamage(float dist, float radius, float damage){
float falloff = 0.4f;
float scaled = Mathf.lerp(1f - dist / radius, 1f, falloff);
return damage * scaled;
}
@Struct
static class PropCellStruct{
byte x;
byte y;
short damage;
/** @return resulting armor calculated based off of damage */
public static float applyArmor(float damage, float armor){
return Math.max(damage - armor, minArmorDamage * damage);
}
public static class Collided implements Pool.Poolable{
public float x, y;
public Teamc target;
public Collided set(float x, float y, Teamc target){
this.x = x;
this.y = y;
this.target = target;
return this;
}
@Override
public void reset(){
target = null;
}
}
}
+103 -30
View File
@@ -10,6 +10,7 @@ import arc.struct.*;
import arc.util.*;
import mindustry.*;
import mindustry.content.*;
import mindustry.entities.effect.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.world.*;
@@ -19,7 +20,8 @@ import static mindustry.Vars.*;
public class Effect{
private static final float shakeFalloff = 10000f;
private static final EffectContainer container = new EffectContainer();
private static final Seq<Effect> all = new Seq<>();
public static final Seq<Effect> all = new Seq<>();
private boolean initialized;
@@ -29,6 +31,14 @@ public class Effect{
public float lifetime = 50f;
/** Clip size. */
public float clip;
/** Time delay before the effect starts */
public float startDelay;
/** Amount added to rotation */
public float baseRotation;
/** If true, parent unit is data are followed. */
public boolean followParent = true;
/** If this and followParent are true, the effect will offset and rotate with the parent's rotation. */
public boolean rotWithParent;
public float layer = Layer.effect;
public float layerDuration;
@@ -51,49 +61,117 @@ public class Effect{
all.add(this);
}
public Effect startDelay(float d){
startDelay = d;
return this;
}
public void init(){}
public Effect followParent(boolean follow){
followParent = follow;
return this;
}
public Effect rotWithParent(boolean follow){
rotWithParent = follow;
return this;
}
public Effect layer(float l){
layer = l;
return this;
}
public Effect baseRotation(float d){
baseRotation = d;
return this;
}
public Effect layer(float l, float duration){
layer = l;
this.layerDuration = duration;
return this;
}
public WrapEffect wrap(Color color){
return new WrapEffect(this, color);
}
public WrapEffect wrap(Color color, float rotation){
return new WrapEffect(this, color, rotation);
}
public void at(Position pos){
create(this, pos.getX(), pos.getY(), 0, Color.white, null);
create(pos.getX(), pos.getY(), 0, Color.white, null);
}
public void at(Position pos, boolean parentize){
create(pos.getX(), pos.getY(), 0, Color.white, parentize ? pos : null);
}
public void at(Position pos, float rotation){
create(this, pos.getX(), pos.getY(), rotation, Color.white, null);
create(pos.getX(), pos.getY(), rotation, Color.white, null);
}
public void at(float x, float y){
create(this, x, y, 0, Color.white, null);
create(x, y, 0, Color.white, null);
}
public void at(float x, float y, float rotation){
create(this, x, y, rotation, Color.white, null);
create(x, y, rotation, Color.white, null);
}
public void at(float x, float y, float rotation, Color color){
create(this, x, y, rotation, color, null);
create(x, y, rotation, color, null);
}
public void at(float x, float y, Color color){
create(this, x, y, 0, color, null);
create(x, y, 0, color, null);
}
public void at(float x, float y, float rotation, Color color, Object data){
create(this, x, y, rotation, color, data);
create(x, y, rotation, color, data);
}
public void at(float x, float y, float rotation, Object data){
create(this, x, y, rotation, Color.white, data);
create(x, y, rotation, Color.white, data);
}
public boolean shouldCreate(){
return !headless && this != Fx.none && Vars.renderer.enableEffects;
}
public void create(float x, float y, float rotation, Color color, Object data){
if(!shouldCreate()) return;
if(Core.camera.bounds(Tmp.r1).overlaps(Tmp.r2.setCentered(x, y, clip))){
if(!initialized){
initialized = true;
init();
}
if(startDelay <= 0f){
add(x, y, rotation, color, data);
}else{
Time.run(startDelay, () -> add(x, y, rotation, color, data));
}
}
}
protected void add(float x, float y, float rotation, Color color, Object data){
var entity = EffectState.create();
entity.effect = this;
entity.rotation = baseRotation + rotation;
entity.data = data;
entity.lifetime = lifetime;
entity.set(x, y);
entity.color.set(color);
if(followParent && data instanceof Posc p){
entity.parent = p;
entity.rotWithParent = rotWithParent;
}
entity.add();
}
public float render(int id, Color color, float life, float lifetime, float rotation, float x, float y, Object data){
@@ -133,28 +211,19 @@ public class Effect{
shake(intensity, duration, loc.getX(), loc.getY());
}
public static void create(Effect effect, float x, float y, float rotation, Color color, Object data){
if(headless || effect == Fx.none) return;
if(Core.settings.getBool("effects")){
Rect view = Core.camera.bounds(Tmp.r1);
Rect pos = Tmp.r2.setSize(effect.clip).setCenter(x, y);
public static void floorDust(float x, float y, float size){
Tile tile = world.tileWorld(x, y);
if(tile != null){
Color color = tile.floor().mapColor;
Fx.unitLand.at(x, y, size, color);
}
}
if(view.overlaps(pos)){
if(!effect.initialized){
effect.initialized = true;
effect.init();
}
EffectState entity = EffectState.create();
entity.effect = effect;
entity.rotation = rotation;
entity.data = (data);
entity.lifetime = (effect.lifetime);
entity.set(x, y);
entity.color.set(color);
if(data instanceof Posc) entity.parent = ((Posc)data);
entity.add();
}
public static void floorDustAngle(Effect effect, float x, float y, float angle){
Tile tile = world.tileWorld(x, y);
if(tile != null){
Color color = tile.floor().mapColor;
effect.at(x, y, angle, color);
}
}
@@ -215,6 +284,10 @@ public class Effect{
return (T)data;
}
public EffectContainer inner(){
return innerContainer == null ? (innerContainer = new EffectContainer()) : innerContainer;
}
public void scaled(float lifetime, Cons<EffectContainer> cons){
if(innerContainer == null) innerContainer = new EffectContainer();
if(time <= lifetime){
@@ -1,28 +1,27 @@
package mindustry.entities;
import arc.func.*;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.gen.*;
import mindustry.world.*;
import static mindustry.Vars.*;
public class EntityCollisions{
//range for tile collision scanning
private static final int r = 1;
//move in 1-unit chunks
//move in 1-unit chunks (can this be made more efficient?)
private static final float seg = 1f;
//tile collisions
private Rect tmp = new Rect();
private Vec2 vector = new Vec2();
private Vec2 l1 = new Vec2();
private Rect r1 = new Rect();
private Rect r2 = new Rect();
private Vec2 vector = new Vec2(), l1 = new Vec2();
private Rect r1 = new Rect(), r2 = new Rect(), tmp = new Rect();
//entity collisions
private Seq<Hitboxc> arrOut = new Seq<>();
private Seq<Hitboxc> arrOut = new Seq<>(Hitboxc.class);
private Cons<Hitboxc> hitCons = this::updateCollision;
private Cons<QuadTree> treeCons = tree -> tree.intersect(r2, arrOut);
public void moveCheck(Hitboxc entity, float deltax, float deltay, SolidPred solidCheck){
if(!solidCheck.solid(entity.tileX(), entity.tileY())){
@@ -35,13 +34,15 @@ public class EntityCollisions{
}
public void move(Hitboxc entity, float deltax, float deltay, SolidPred solidCheck){
if(Math.abs(deltax) < 0.0001f & Math.abs(deltay) < 0.0001f) return;
if(Math.abs(deltax) < 0.0001f & Math.abs(deltay) < 0.0001f) return;
boolean movedx = false;
entity.hitboxTile(r1);
int r = Math.max(Math.round(r1.width / tilesize), 1);
while(Math.abs(deltax) > 0 || !movedx){
movedx = true;
moveDelta(entity, Math.min(Math.abs(deltax), seg) * Mathf.sign(deltax), 0, true, solidCheck);
moveDelta(entity, Math.min(Math.abs(deltax), seg) * Mathf.sign(deltax), 0, r, true, solidCheck);
if(Math.abs(deltax) >= seg){
deltax -= seg * Mathf.sign(deltax);
@@ -54,7 +55,7 @@ public class EntityCollisions{
while(Math.abs(deltay) > 0 || !movedy){
movedy = true;
moveDelta(entity, 0, Math.min(Math.abs(deltay), seg) * Mathf.sign(deltay), false, solidCheck);
moveDelta(entity, 0, Math.min(Math.abs(deltay), seg) * Mathf.sign(deltay), r, false, solidCheck);
if(Math.abs(deltay) >= seg){
deltay -= seg * Mathf.sign(deltay);
@@ -64,7 +65,7 @@ public class EntityCollisions{
}
}
public void moveDelta(Hitboxc entity, float deltax, float deltay, boolean x, SolidPred solidCheck){
public void moveDelta(Hitboxc entity, float deltax, float deltay, int r, boolean x, SolidPred solidCheck){
entity.hitboxTile(r1);
entity.hitboxTile(r2);
r1.x += deltax;
@@ -80,8 +81,8 @@ public class EntityCollisions{
if(tmp.overlaps(r1)){
Vec2 v = Geometry.overlap(r1, tmp, x);
if(x) r1.x += v.x;
if(!x) r1.y += v.y;
r1.x += v.x;
r1.y += v.y;
}
}
}
@@ -90,9 +91,11 @@ public class EntityCollisions{
entity.trns(r1.x - r2.x, r1.y - r2.y);
}
public boolean overlapsTile(Rect rect){
public boolean overlapsTile(Rect rect, @Nullable SolidPred solidChecker){
if(solidChecker == null) return false;
rect.getCenter(vector);
int r = 1;
int r = Math.max(Math.round(r1.width / tilesize), 1);
//assumes tiles are centered
int tilex = Math.round(vector.x / tilesize);
@@ -101,10 +104,9 @@ public class EntityCollisions{
for(int dx = -r; dx <= r; dx++){
for(int dy = -r; dy <= r; dy++){
int wx = dx + tilex, wy = dy + tiley;
if(solid(wx, wy)){
r2.setSize(tilesize).setCenter(wx * tilesize, wy * tilesize);
if(solidChecker.solid(wx, wy)){
if(r2.overlaps(rect)){
if(r2.setCentered(wx * tilesize, wy * tilesize, tilesize).overlaps(rect)){
return true;
}
}
@@ -115,7 +117,7 @@ public class EntityCollisions{
@SuppressWarnings("unchecked")
public <T extends Hitboxc> void updatePhysics(EntityGroup<T> group){
QuadTree tree = group.tree();
var tree = group.tree();
tree.clear();
group.each(s -> {
@@ -126,12 +128,12 @@ public class EntityCollisions{
public static boolean legsSolid(int x, int y){
Tile tile = world.tile(x, y);
return tile == null || tile.staticDarkness() >= 2 || tile.floor().solid;
return tile == null || tile.legSolid();
}
public static boolean waterSolid(int x, int y){
Tile tile = world.tile(x, y);
return tile == null || (tile.solid() || !tile.floor().isLiquid);
return tile == null || tile.solid() || !tile.floor().isLiquid;
}
public static boolean solid(int x, int y){
@@ -153,7 +155,7 @@ public class EntityCollisions{
float vbx = b.getX() - b.lastX();
float vby = b.getY() - b.lastY();
if(a != b && a.collides(b)){
if(a != b && a.collides(b) && b.collides(a)){
l1.set(a.getX(), a.getY());
boolean collide = r1.overlaps(r2) || collide(r1.x, r1.y, r1.width, r1.height, vax, vay,
r2.x, r2.y, r2.width, r2.height, vbx, vby, l1);
@@ -190,14 +192,10 @@ public class EntityCollisions{
yInvExit = y2 - (y1 + h1);
}
float xEntry, yEntry;
float xExit, yExit;
xEntry = xInvEntry / vx1;
xExit = xInvExit / vx1;
yEntry = yInvEntry / vy1;
yExit = yInvExit / vy1;
float xEntry = xInvEntry / vx1;
float xExit = xInvExit / vx1;
float yEntry = yInvEntry / vy1;
float yExit = yInvExit / vy1;
float entryTime = Math.max(xEntry, yEntry);
float exitTime = Math.min(xExit, yExit);
@@ -216,31 +214,37 @@ public class EntityCollisions{
@SuppressWarnings("unchecked")
public <T extends Hitboxc> void collide(EntityGroup<T> groupa){
groupa.each(solid -> {
solid.hitbox(r1);
r1.x += (solid.lastX() - solid.getX());
r1.y += (solid.lastY() - solid.getY());
groupa.each((Cons<T>)hitCons);
}
solid.hitbox(r2);
r2.merge(r1);
private void updateCollision(Hitboxc solid){
solid.hitbox(r1);
r1.x += (solid.lastX() - solid.getX());
r1.y += (solid.lastY() - solid.getY());
arrOut.clear();
solid.hitbox(r2);
r2.merge(r1);
//get all targets based on what entity wants to collide with
solid.getCollisions(tree -> tree.intersect(r2, arrOut));
arrOut.clear();
for(Hitboxc sc : arrOut){
sc.hitbox(r1);
if(r2.overlaps(r1)){
checkCollide(solid, sc);
//break out of loop when this object hits something
if(!solid.isAdded()) return;
}
//get all targets based on what entity wants to collide with
solid.getCollisions(treeCons);
var items = arrOut.items;
int size = arrOut.size;
for(int i = 0; i < size; i++){
Hitboxc sc = items[i];
sc.hitbox(r1);
if(r2.overlaps(r1)){
checkCollide(solid, sc);
//break out of loop when this object hits something
if(!solid.isAdded()) return;
}
});
}
}
public interface SolidPred{
boolean solid(int x, int y);
}
}
}
+90 -7
View File
@@ -20,6 +20,7 @@ public class EntityGroup<T extends Entityc> implements Iterable<T>{
private final Seq<T> intersectArray = new Seq<>();
private final Rect viewport = new Rect();
private final Rect intersectRect = new Rect();
private final EntityIndexer indexer;
private IntMap<T> map;
private QuadTree tree;
private boolean clearing;
@@ -27,10 +28,20 @@ public class EntityGroup<T extends Entityc> implements Iterable<T>{
private int index;
public static int nextId(){
if(lastId >= Integer.MAX_VALUE - 2) lastId = 0;
return lastId++;
}
/** Makes sure the next ID counter is higher than this number, so future entities cannot possibly use this ID. */
public static void checkNextId(int id){
lastId = Math.max(lastId, id + 1);
}
public EntityGroup(Class<T> type, boolean spatial, boolean mapping){
this(type, spatial, mapping, null);
}
public EntityGroup(Class<T> type, boolean spatial, boolean mapping, EntityIndexer indexer){
array = new Seq<>(false, 32, type);
if(spatial){
@@ -40,6 +51,20 @@ public class EntityGroup<T extends Entityc> implements Iterable<T>{
if(mapping){
map = new IntMap<>();
}
this.indexer = indexer;
}
/** @return entities with colliding IDs, or an empty array. */
public Seq<T> checkIDCollisions(){
Seq<T> out = new Seq<>();
IntSet ints = new IntSet();
each(u -> {
if(!ints.add(u.id())){
out.add(u);
}
});
return out;
}
public void sort(Comparator<? super T> comp){
@@ -55,7 +80,13 @@ public class EntityGroup<T extends Entityc> implements Iterable<T>{
}
public void update(){
each(Entityc::update);
for(index = 0; index < array.size; index++){
array.items[index].update();
}
}
public Seq<T> copy(){
return copy(new Seq<>());
}
public Seq<T> copy(Seq<T> arr){
@@ -78,16 +109,17 @@ public class EntityGroup<T extends Entityc> implements Iterable<T>{
public void draw(Cons<T> cons){
Core.camera.bounds(viewport);
each(e -> {
Drawc draw = (Drawc)e;
if(viewport.overlaps(draw.x() - draw.clipSize()/2f, draw.y() - draw.clipSize()/2f, draw.clipSize(), draw.clipSize())){
cons.get(e);
for(index = 0; index < array.size; index++){
Drawc draw = (Drawc)array.items[index];
float clip = draw.clipSize();
if(viewport.overlaps(draw.x() - clip/2f, draw.y() - clip/2f, clip, clip)){
cons.get((T)draw);
}
});
}
}
public boolean useTree(){
return map != null;
return tree != null;
}
public boolean mappingEnabled(){
@@ -114,6 +146,12 @@ public class EntityGroup<T extends Entityc> implements Iterable<T>{
tree.intersect(x, y, width, height, out);
}
public boolean intersect(float x, float y, float width, float height, Boolf<? super T> out){
//don't waste time for empty groups
if(isEmpty()) return false;
return tree.intersect(x, y, width, height, out);
}
public Seq<T> intersect(float x, float y, float width, float height){
intersectArray.clear();
//don't waste time for empty groups
@@ -163,12 +201,25 @@ public class EntityGroup<T extends Entityc> implements Iterable<T>{
}
}
public int addIndex(T type){
int index = array.size;
add(type);
return index;
}
public void remove(T type){
if(clearing) return;
if(type == null) throw new RuntimeException("Cannot remove a null entity!");
int idx = array.indexOf(type, true);
if(idx != -1){
array.remove(idx);
//fix incorrect HEAD index since it was swapped
if(array.size > 0 && idx != array.size){
var swapped = array.items[idx];
if(indexer != null) indexer.change(swapped, idx);
}
if(map != null){
map.remove(type.id());
}
@@ -180,6 +231,38 @@ public class EntityGroup<T extends Entityc> implements Iterable<T>{
}
}
public void removeIndex(T type, int position){
if(clearing) return;
if(type == null) throw new RuntimeException("Cannot remove a null entity!");
if(position != -1 && position < array.size){
//rarely the entity index is wrong; fallback to slow implementation
if(array.items[position] != type){
remove(type);
return;
}
//swap head with current
if(array.size > 1){
var head = array.items[array.size - 1];
if(indexer != null) indexer.change(head, position);
array.items[position] = head;
}
array.size --;
array.items[array.size] = null;
if(map != null){
map.remove(type.id());
}
//fix iteration index when removing
if(index >= position){
index --;
}
}
}
public void clear(){
clearing = true;

Some files were not shown because too many files have changed in this diff Show More