diff --git a/core/assets/bundles/bundle.properties b/core/assets/bundles/bundle.properties index c7d8db925b..1b327a02cd 100644 --- a/core/assets/bundles/bundle.properties +++ b/core/assets/bundles/bundle.properties @@ -1212,6 +1212,8 @@ rules.wavetimer = Wave Timer rules.wavesending = Wave Sending rules.waves = Waves rules.attack = Attack Mode +rules.buildai = Base Builder AI +rules.buildaitier = Builder AI Tier rules.rtsai = RTS AI [red](WIP) rules.rtsminsquadsize = Min Squad Size rules.rtsmaxsquadsize = Max Squad Size diff --git a/core/assets/maps/glacier.msav b/core/assets/maps/glacier.msav index efe240108d..cdb524a0d4 100644 Binary files a/core/assets/maps/glacier.msav and b/core/assets/maps/glacier.msav differ diff --git a/core/assets/maps/passage.msav b/core/assets/maps/passage.msav index e9b8c5b1d1..fb14ff3032 100644 Binary files a/core/assets/maps/passage.msav and b/core/assets/maps/passage.msav differ diff --git a/core/assets/maps/veins.msav b/core/assets/maps/veins.msav index 037d3f0b75..ad6a8b5c01 100644 Binary files a/core/assets/maps/veins.msav and b/core/assets/maps/veins.msav differ diff --git a/core/src/mindustry/ai/BaseBuilderAI.java b/core/src/mindustry/ai/BaseBuilderAI.java new file mode 100644 index 0000000000..807458b349 --- /dev/null +++ b/core/src/mindustry/ai/BaseBuilderAI.java @@ -0,0 +1,325 @@ +package mindustry.ai; + +import arc.math.*; +import arc.math.geom.*; +import arc.struct.*; +import arc.util.*; +import mindustry.*; +import mindustry.ai.BaseRegistry.*; +import mindustry.content.*; +import mindustry.core.*; +import mindustry.game.*; +import mindustry.game.Schematic.*; +import mindustry.game.Teams.*; +import mindustry.gen.*; +import mindustry.maps.generators.*; +import mindustry.type.*; +import mindustry.world.*; +import mindustry.world.blocks.defense.*; +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 BaseBuilderAI{ + private static final Vec2 axis = new Vec2(), rotator = new Vec2(); + private static final int attempts = 4; + 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 tmpTiles = new Seq<>(); + + private static int correct = 0, incorrect = 0; + + private int lastX, lastY, lastW, lastH; + private boolean triedWalls, foundPath; + + final TeamData data; + final Interval timer = new Interval(4); + + IntSet path = new IntSet(); + IntSet calcPath = new IntSet(); + @Nullable Tile calcTile; + boolean calculating, startedCalculating; + int calcCount = 0; + int totalCalcs = 0; + Block wallType; + + public BaseBuilderAI(TeamData data){ + this.data = data; + } + + public void update(){ + + //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(wallType == null){ + wallType = BaseGenerator.getDifficultyWall(1, data.team.rules().buildAiTier / 0.8f); + } + + if(data.team.rules().aiCoreSpawn && timer.get(timerSpawn, 60 * 6f) && data.hasCore()){ + CoreBlock block = (CoreBlock)data.core().block; + int coreUnits = data.countType(block.unitType); + + //create AI core unit(s) + if(!state.isEditor() && coreUnits < data.cores.size){ + Unit unit = block.unitType.create(data.team); + unit.set(data.cores.random()); + unit.add(); + Fx.spawn.at(unit); + } + } + + //refresh path + if(!calculating && (timer.get(timerRefreshPath, 3f * Time.toMinutes) || !startedCalculating) && data.hasCore()){ + calculating = true; + startedCalculating = true; + calcPath.clear(); + } + + //didn't find tile in time + if(calculating && calcCount >= world.width() * world.height()){ + calculating = false; + calcCount = 0; + calcPath.clear(); + totalCalcs ++; + } + + //calculate path for units so schematics are not placed on it + if(calculating){ + if(calcTile == null){ + Vars.spawner.eachGroundSpawn((x, y) -> calcTile = world.tile(x, y)); + if(calcTile == null){ + calculating = false; + } + }else{ + var field = pathfinder.getField(data.team, Pathfinder.costGround, Pathfinder.fieldCore); + + if(field.weights != null){ + 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, packed = world.packArray(nx, ny); + + 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 ++; + } + } + } + } + + //only schedule when there's something to build. + if(foundPath && data.plans.isEmpty() && timer.get(timerStep, Mathf.lerp(placeIntervalMin, placeIntervalMax, data.team.rules().buildAiTier))){ + //TODO walls are silly, no walls + //if(!triedWalls){ + // tryWalls(); + // triedWalls = true; + //} + + for(int i = 0; i < attempts; i++){ + int range = 150; + + Position pos = randomPosition(); + + //when there are no random positions, do nothing. + if(pos == null) return; + + Tmp.v1.rnd(Mathf.random(range)); + int wx = (int)(World.toTile(pos.getX()) + Tmp.v1.x), wy = (int)(World.toTile(pos.getY()) + Tmp.v1.y); + Tile tile = world.tiles.getc(wx, wy); + + //try not to block the spawn point + if(spawner.getSpawns().contains(t -> t.within(tile, tilesize * 40f))){ + continue; + } + + Seq parts = null; + + //pick a completely random base part, and place it a random location + //((yes, very intelligent)) + if(tile.drop() != null && Vars.bases.forResource(tile.drop()).any()){ + parts = Vars.bases.forResource(tile.drop()); + }else if(Mathf.chance(emptyChance)){ + parts = Vars.bases.parts; + } + + if(parts != null){ + BasePart part = parts.random(); + if(tryPlace(part, tile.x, tile.y)){ + break; + } + } + } + } + } + + /** @return a random position from which to seed building. */ + private Position randomPosition(){ + if(data.hasCore()){ + return data.cores.random(); + }else if(data.team == state.rules.waveTeam){ + return spawner.getSpawns().random(); + } + return null; + } + + private boolean tryPlace(BasePart part, int x, int y){ + int rotation = Mathf.range(2); + axis.set((int)(part.schematic.width / 2f), (int)(part.schematic.height / 2f)); + Schematic result = Schematics.rotate(part.schematic, rotation); + int rotdeg = rotation*90; + rotator.set(part.centerX, part.centerY).rotateAround(axis, rotdeg); + //bottom left schematic corner + int cx = x - (int)rotator.x; + int cy = y - (int)rotator.y; + + //check valid placeability + for(Stile tile : result.tiles){ + int realX = tile.x + cx, realY = tile.y + cy; + if(!Build.validPlace(tile.block, data.team, realX, realY, tile.rotation)){ + return false; + } + 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()))){ + return false; + } + } + + //make sure at least X% of resource requirements are met + correct = incorrect = 0; + boolean anyDrills = false; + + if(part.required instanceof Item){ + for(Stile tile : result.tiles){ + if(tile.block instanceof Drill){ + anyDrills = true; + + tile.block.iterateTaken(tile.x + cx, tile.y + cy, (ex, ey) -> { + Tile res = world.rawTile(ex, ey); + if(res.drop() == part.required){ + correct ++; + }else if(res.drop() != null){ + incorrect ++; + } + }); + } + } + } + + //fail if not enough fit requirements + if(anyDrills && (incorrect != 0 || correct == 0)){ + return false; + } + + //queue it + for(Stile tile : result.tiles){ + data.plans.add(new BlockPlan(cx + tile.x, cy + tile.y, tile.rotation, tile.block.id, 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 = wallType; + 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++){ + outer: + 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 PayloadBlock || o.block() instanceof PayloadConveyor || o.block() instanceof ShockMine)){ + continue outer; + } + + if(o != null && o.team() == data.team && !(o.block() instanceof Wall)){ + any = true; + } + } + + 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.plans.add(new BlockPlan(tile.x, tile.y, (short)0, wall.id, null)); + } + } + } + } +} \ No newline at end of file diff --git a/core/src/mindustry/ai/types/BuilderAI.java b/core/src/mindustry/ai/types/BuilderAI.java index 6c71f5f934..a8ea3118cf 100644 --- a/core/src/mindustry/ai/types/BuilderAI.java +++ b/core/src/mindustry/ai/types/BuilderAI.java @@ -12,14 +12,14 @@ import mindustry.world.blocks.ConstructBlock.*; import static mindustry.Vars.*; public class BuilderAI extends AIController{ - public static float buildRadius = 1500, retreatDst = 110f, retreatDelay = Time.toSeconds * 2f; + 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 = 60f * 2f; + public float fleeRange = 370f, rebuildPeriod = defaultRebuildPeriod; public boolean alwaysFlee; public boolean onlyAssist; @@ -34,6 +34,14 @@ public class BuilderAI extends AIController{ 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(){ diff --git a/core/src/mindustry/core/Logic.java b/core/src/mindustry/core/Logic.java index bde8089da8..b312e93374 100644 --- a/core/src/mindustry/core/Logic.java +++ b/core/src/mindustry/core/Logic.java @@ -445,6 +445,12 @@ public class Logic implements ApplicationListener{ updateWeather(); for(TeamData data : state.teams.getActive()){ + //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(); diff --git a/core/src/mindustry/game/Rules.java b/core/src/mindustry/game/Rules.java index f6445db9ec..177021f1cb 100644 --- a/core/src/mindustry/game/Rules.java +++ b/core/src/mindustry/game/Rules.java @@ -267,6 +267,11 @@ public class Rules{ /** If true, this team has infinite unit ammo. */ public boolean infiniteAmmo; + /** AI that builds random schematics. */ + public boolean buildAi; + /** Tier of builder AI. [0, 1] */ + public float buildAiTier = 1f; + /** Enables "RTS" unit AI. */ public boolean rtsAi; /** Minimum size of attack squads. */ diff --git a/core/src/mindustry/game/Teams.java b/core/src/mindustry/game/Teams.java index 87eebbdbf0..e47b5bfb13 100644 --- a/core/src/mindustry/game/Teams.java +++ b/core/src/mindustry/game/Teams.java @@ -241,6 +241,8 @@ public class Teams{ public static class TeamData{ public final Team team; + /** Handles building ""bases"". */ + public @Nullable BaseBuilderAI buildAi; /** Handles RTS unit control. */ public @Nullable RtsAI rtsAi; @@ -409,7 +411,7 @@ public class Teams{ /** @return whether this team is controlled by the AI and builds bases. */ public boolean hasAI(){ - return team.rules().rtsAi; + return team.rules().rtsAi || team.rules().buildAi; } @Override diff --git a/core/src/mindustry/ui/dialogs/CustomRulesDialog.java b/core/src/mindustry/ui/dialogs/CustomRulesDialog.java index 4f35ae48ac..f1f574f954 100644 --- a/core/src/mindustry/ui/dialogs/CustomRulesDialog.java +++ b/core/src/mindustry/ui/dialogs/CustomRulesDialog.java @@ -300,6 +300,10 @@ public class CustomRulesDialog extends BaseDialog{ numberi("@rules.rtsmaxsquadsize", f -> teams.rtsMaxSquad = f, () -> teams.rtsMaxSquad, () -> teams.rtsAi, 1, 1000); number("@rules.rtsminattackweight", f -> teams.rtsMinWeight = f, () -> teams.rtsMinWeight, () -> teams.rtsAi); + check("@rules.buildai", b -> teams.buildAi = b, () -> teams.buildAi, () -> team != rules.defaultTeam && rules.env != Planets.erekir.defaultEnv && !rules.pvp); + //disallow on Erekir (this is broken for mods I'm sure, but whatever) + number("@rules.buildaitier", false, f -> teams.buildAiTier = f, () -> teams.buildAiTier, () -> teams.buildAi && rules.env != Planets.erekir.defaultEnv && !rules.pvp, 0, 1); + check("@rules.infiniteresources", b -> teams.infiniteResources = b, () -> teams.infiniteResources); number("@rules.buildspeedmultiplier", f -> teams.buildSpeedMultiplier = f, () -> teams.buildSpeedMultiplier, 0.001f, 50f);