From 2aab745603d256aaa84968a6f5c9777c171e2732 Mon Sep 17 00:00:00 2001 From: Anuken Date: Sun, 24 Sep 2023 07:44:42 -0400 Subject: [PATCH] WIP grouped unit arrival --- core/src/mindustry/ai/ControlPathfinder.java | 24 + core/src/mindustry/ai/UnitCommand.java | 2 +- core/src/mindustry/ai/UnitGroup.java | 443 ++++++++++++++++++ core/src/mindustry/ai/types/CommandAI.java | 73 +-- core/src/mindustry/async/PhysicsProcess.java | 14 +- core/src/mindustry/content/Fx.java | 20 +- .../src/mindustry/entities/comp/UnitComp.java | 6 + core/src/mindustry/input/InputHandler.java | 43 +- .../type/weapons/PointDefenseWeapon.java | 1 + 9 files changed, 566 insertions(+), 60 deletions(-) create mode 100644 core/src/mindustry/ai/UnitGroup.java diff --git a/core/src/mindustry/ai/ControlPathfinder.java b/core/src/mindustry/ai/ControlPathfinder.java index 4ff9f7e384..2b404e5671 100644 --- a/core/src/mindustry/ai/ControlPathfinder.java +++ b/core/src/mindustry/ai/ControlPathfinder.java @@ -397,6 +397,30 @@ public class ControlPathfinder{ return true; } + /** @return 0 if nothing was hit, otherwise the packed coordinates. This is an internal function and will likely be moved - do not use!*/ + public static int raycastFast(int team, PathCost type, int x1, int y1, int x2, int y2){ + int ww = world.width(), wh = world.height(); + 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 err = dx - dy; + + while(x >= 0 && y >= 0 && x < ww && y < wh){ + if(solid(team, type, x + y * wwidth, true)) return Point2.pack(x, y); + if(x == x2 && y == y2) return 0; + + //no diagonals + if(2 * err + dy > dx - 2 * err){ + err -= dy; + x += sx; + }else{ + err += dx; + y += sy; + } + } + + return 0; + } + static boolean cast(int team, PathCost cost, int from, int to){ return raycast(team, cost, from % wwidth, from / wwidth, to % wwidth, to / wwidth); } diff --git a/core/src/mindustry/ai/UnitCommand.java b/core/src/mindustry/ai/UnitCommand.java index cc0d04d116..d841b6ebd9 100644 --- a/core/src/mindustry/ai/UnitCommand.java +++ b/core/src/mindustry/ai/UnitCommand.java @@ -55,7 +55,7 @@ public class UnitCommand extends MappableContent{ } public char getEmoji() { - return (char) Iconc.codes.get(icon, Iconc.cancel); + return (char)Iconc.codes.get(icon, Iconc.cancel); } @Override diff --git a/core/src/mindustry/ai/UnitGroup.java b/core/src/mindustry/ai/UnitGroup.java new file mode 100644 index 0000000000..0e78137ebe --- /dev/null +++ b/core/src/mindustry/ai/UnitGroup.java @@ -0,0 +1,443 @@ +package mindustry.ai; + +import arc.*; +import arc.func.*; +import arc.graphics.*; +import arc.math.*; +import arc.math.geom.*; +import arc.struct.*; +import mindustry.*; +import mindustry.ai.Pathfinder.*; +import mindustry.async.*; +import mindustry.content.*; +import mindustry.core.*; +import mindustry.gen.*; + +public class UnitGroup{ + public Seq units = new Seq<>(); + public float[] positions; + public volatile boolean valid; + + public void calculateFormation(Vec2 dest, int 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/2f; + + 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; + } + } + } + } + } + } + + //raycast from the destination to the offset to make sure it's reachable + if(collisionLayer != PhysicsProcess.layerFlying){ + for(int a = 0; a < units.size; a ++){ + //coordinates in world space + float + x = positions[a * 2] + dest.x, + y = positions[a * 2 + 1] + dest.y; + + Unit unit = units.get(a); + + PathCost cost = unit.type.pathCost; + int res = ControlPathfinder.raycastFast(unit.team.id, cost, World.toTile(dest.x), World.toTile(dest.y), World.toTile(x), World.toTile(y)); + + //collision found, make th 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[a * 2] = v1.x; + positions[a * 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)})); + } + } + } + + 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 static class IntQuadTree{ + protected final Rect tmp = new Rect(); + protected static final int maxObjectsPerNode = 5; + + public IntQuadTreeProvider prov; + public Rect bounds; + public IntSeq objects = new IntSeq(false, 10); + public IntQuadTree botLeft, botRight, topLeft, topRight; + public boolean leaf = true; + public int totalObjects; + + public IntQuadTree(Rect bounds, IntQuadTreeProvider prov){ + this.bounds = bounds; + this.prov = prov; + } + + protected void split(){ + if(!leaf) return; + + float subW = bounds.width / 2; + float subH = bounds.height / 2; + + if(botLeft == null){ + botLeft = newChild(new Rect(bounds.x, bounds.y, subW, subH)); + botRight = newChild(new Rect(bounds.x + subW, bounds.y, subW, subH)); + topLeft = newChild(new Rect(bounds.x, bounds.y + subH, subW, subH)); + topRight = newChild(new Rect(bounds.x + subW, bounds.y + subH, subW, subH)); + } + leaf = false; + + // Transfer objects to children if they fit entirely in one + for(int i = 0; i < objects.size; i ++){ + int obj = objects.items[i]; + hitbox(obj); + IntQuadTree child = getFittingChild(tmp); + if(child != null){ + child.insert(obj); + objects.removeIndex(i); + i --; + } + } + } + + protected void unsplit(){ + if(leaf) return; + objects.addAll(botLeft.objects); + objects.addAll(botRight.objects); + objects.addAll(topLeft.objects); + objects.addAll(topRight.objects); + botLeft.clear(); + botRight.clear(); + topLeft.clear(); + topRight.clear(); + leaf = true; + } + + /** + * Inserts an object into this node or its child nodes. This will split a leaf node if it exceeds the object limit. + */ + public void insert(int obj){ + hitbox(obj); + if(!bounds.overlaps(tmp)){ + // New object not in quad tree, ignoring + // throw an exception? + return; + } + + totalObjects ++; + + if(leaf && objects.size + 1 > maxObjectsPerNode) split(); + + if(leaf){ + // Leaf, so no need to add to children, just add to root + objects.add(obj); + }else{ + hitbox(obj); + // Add to relevant child, or root if can't fit completely in a child + IntQuadTree child = getFittingChild(tmp); + if(child != null){ + child.insert(obj); + }else{ + objects.add(obj); + } + } + } + + /** + * Removes an object from this node or its child nodes. + */ + public boolean remove(int obj){ + boolean result; + if(leaf){ + // Leaf, no children, remove from root + result = objects.removeValue(obj); + }else{ + // Remove from relevant child + hitbox(obj); + IntQuadTree child = getFittingChild(tmp); + + if(child != null){ + result = child.remove(obj); + }else{ + // Or root if object doesn't fit in a child + result = objects.removeValue(obj); + } + + if(totalObjects <= maxObjectsPerNode) unsplit(); + } + if(result){ + totalObjects --; + } + return result; + } + + /** Removes all objects. */ + public void clear(){ + objects.clear(); + totalObjects = 0; + if(!leaf){ + topLeft.clear(); + topRight.clear(); + botLeft.clear(); + botRight.clear(); + } + leaf = true; + } + + protected IntQuadTree getFittingChild(Rect boundingBox){ + float verticalMidpoint = bounds.x + (bounds.width / 2); + float horizontalMidpoint = bounds.y + (bounds.height / 2); + + // Object can completely fit within the top quadrants + boolean topQuadrant = boundingBox.y > horizontalMidpoint; + // Object can completely fit within the bottom quadrants + boolean bottomQuadrant = boundingBox.y < horizontalMidpoint && (boundingBox.y + boundingBox.height) < horizontalMidpoint; + + // Object can completely fit within the left quadrants + if(boundingBox.x < verticalMidpoint && boundingBox.x + boundingBox.width < verticalMidpoint){ + if(topQuadrant){ + return topLeft; + }else if(bottomQuadrant){ + return botLeft; + } + }else if(boundingBox.x > verticalMidpoint){ // Object can completely fit within the right quadrants + if(topQuadrant){ + return topRight; + }else if(bottomQuadrant){ + return botRight; + } + } + + // Else, object needs to be in parent cause it can't fit completely in a quadrant + return null; + } + + /** + * Processes objects that may intersect the given rectangle. + *

+ * This will never result in false positives. + */ + public void intersect(float x, float y, float width, float height, Intc out){ + if(!leaf){ + if(topLeft.bounds.overlaps(x, y, width, height)) topLeft.intersect(x, y, width, height, out); + if(topRight.bounds.overlaps(x, y, width, height)) topRight.intersect(x, y, width, height, out); + if(botLeft.bounds.overlaps(x, y, width, height)) botLeft.intersect(x, y, width, height, out); + if(botRight.bounds.overlaps(x, y, width, height)) botRight.intersect(x, y, width, height, out); + } + + IntSeq objects = this.objects; + + for(int i = 0; i < objects.size; i++){ + int item = objects.items[i]; + hitbox(item); + if(tmp.overlaps(x, y, width, height)){ + out.get(item); + } + } + } + + /** + * @return whether an object overlaps this rectangle. + * This will never result in false positives. + */ + public boolean any(float x, float y, float width, float height){ + if(!leaf){ + if(topLeft.bounds.overlaps(x, y, width, height) && topLeft.any(x, y, width, height)) return true; + if(topRight.bounds.overlaps(x, y, width, height) && topRight.any(x, y, width, height)) return true; + if(botLeft.bounds.overlaps(x, y, width, height) && botLeft.any(x, y, width, height)) return true; + if(botRight.bounds.overlaps(x, y, width, height) && botRight.any(x, y, width, height))return true; + } + + IntSeq objects = this.objects; + + for(int i = 0; i < objects.size; i++){ + int item = objects.items[i]; + hitbox(item); + if(tmp.overlaps(x, y, width, height)){ + return true; + } + } + return false; + } + + /** + * Processes objects that may intersect the given rectangle. + *

+ * This will never result in false positives. + */ + public void intersect(Rect rect, Intc out){ + intersect(rect.x, rect.y, rect.width, rect.height, out); + } + + /** + * Fills the out parameter with any objects that may intersect the given rectangle. + *

+ * This will result in false positives, but never a false negative. + */ + public void intersect(Rect toCheck, IntSeq out){ + intersect(toCheck.x, toCheck.y, toCheck.width, toCheck.height, out); + } + + /** + * Fills the out parameter with any objects that may intersect the given rectangle. + */ + public void intersect(float x, float y, float width, float height, IntSeq out){ + if(!leaf){ + if(topLeft.bounds.overlaps(x, y, width, height)) topLeft.intersect(x, y, width, height, out); + if(topRight.bounds.overlaps(x, y, width, height)) topRight.intersect(x, y, width, height, out); + if(botLeft.bounds.overlaps(x, y, width, height)) botLeft.intersect(x, y, width, height, out); + if(botRight.bounds.overlaps(x, y, width, height)) botRight.intersect(x, y, width, height, out); + } + + IntSeq objects = this.objects; + + for(int i = 0; i < objects.size; i++){ + int item = objects.items[i]; + hitbox(item); + if(tmp.overlaps(x, y, width, height)){ + out.add(item); + } + } + } + + /** Adds all quadtree objects to the specified Seq. */ + public void getObjects(IntSeq out){ + out.addAll(objects); + + if(!leaf){ + topLeft.getObjects(out); + topRight.getObjects(out); + botLeft.getObjects(out); + botRight.getObjects(out); + } + } + + protected IntQuadTree newChild(Rect rect){ + return new IntQuadTree(rect, prov); + } + + protected void hitbox(int t){ + prov.hitbox(t, tmp); + } + + /**Represents an object in a QuadTree.*/ + public interface IntQuadTreeProvider{ + /**Fills the out parameter with this element's rough bounding box. This should never be smaller than the actual object, but may be larger.*/ + void hitbox(int object, Rect out); + } + } + +} diff --git a/core/src/mindustry/ai/types/CommandAI.java b/core/src/mindustry/ai/types/CommandAI.java index 65181c0884..cb57fe944f 100644 --- a/core/src/mindustry/ai/types/CommandAI.java +++ b/core/src/mindustry/ai/types/CommandAI.java @@ -1,6 +1,5 @@ package mindustry.ai.types; -import arc.math.*; import arc.math.geom.*; import arc.struct.*; import arc.util.*; @@ -18,21 +17,21 @@ import static mindustry.Vars.*; public class CommandAI extends AIController{ protected static final int maxCommandQueueSize = 50; - protected static final float localInterval = 40f; - protected static final Vec2 vecOut = new Vec2(), flockVec = new Vec2(), separation = new Vec2(), cohesion = new Vec2(), massCenter = new Vec2(); + protected static final Vec2 vecOut = new Vec2(), vecMovePos = new Vec2(); protected static final boolean[] noFound = {false}; public Seq 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); protected boolean stopAtTarget, stopWhenInRange; protected Vec2 lastTargetPos; protected int pathId = -1; - protected Seq local = new Seq<>(false); - protected boolean flocked; /** Stance, usually related to firing mode. */ public UnitStance stance = UnitStance.shoot; @@ -176,24 +175,6 @@ public class CommandAI extends AIController{ finishPath(); } - if(targetPos != null){ - if(timer.get(timerTarget3, localInterval) || !flocked){ - if(!flocked){ - //make sure updates are staggered randomly - timer.reset(timerTarget3, Mathf.random(localInterval)); - } - - local.clear(); - //TODO experiment with 2/3/4 - float size = unit.hitSize * 3f; - unit.team.data().tree().intersect(unit.x - size / 2f, unit.y - size/2f, size, size, local); - local.remove(unit); - flocked = true; - } - }else{ - flocked = false; - } - if(attackTarget != null){ if(targetPos == null){ targetPos = new Vec2(); @@ -210,8 +191,13 @@ public class CommandAI extends AIController{ } if(targetPos != null){ - boolean move = true; + boolean move = true, isFinalPoint = commandQueue.size == 0; vecOut.set(targetPos); + vecMovePos.set(targetPos); + + if(group != null && group.valid && groupIndex < group.units.size){ + vecMovePos.add(group.positions[groupIndex * 2], group.positions[groupIndex * 2 + 1]); + } //TODO: should the unit stop when it finds a target? if(stance == UnitStance.patrol && target != null && unit.within(target, unit.type.range - 2f)){ @@ -219,10 +205,12 @@ public class CommandAI extends AIController{ } if(unit.isGrounded() && stance != UnitStance.ram){ - move = Vars.controlPath.getPathPosition(unit, pathId, targetPos, vecOut, noFound); + move = Vars.controlPath.getPathPosition(unit, pathId, vecMovePos, vecOut, noFound); + //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(targetPos.x), World.toTile(targetPos.y)) )){ + 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()); } @@ -230,6 +218,8 @@ public class CommandAI extends AIController{ finishPath(); return; } + }else{ + vecOut.set(vecMovePos); } float engageRange = unit.type.range - 10f; @@ -239,8 +229,6 @@ public class CommandAI extends AIController{ target = attackTarget; circleAttack(80f); }else{ - boolean isFinalPoint = targetPos.epsilonEquals(vecOut, 4.1f) && commandQueue.size == 0; - moveTo(vecOut, attackTarget != null && unit.within(attackTarget, engageRange) && stance != UnitStance.ram ? engageRange : unit.isGrounded() ? 0f : @@ -255,31 +243,17 @@ public class CommandAI extends AIController{ } if(unit.isFlying()){ - unit.lookAt(targetPos); + unit.lookAt(vecMovePos); }else{ faceTarget(); } - if(attackTarget == null){ - if(unit.within(targetPos, Math.max(5f, unit.hitSize / 2f))){ - finishPath(); - }else if(local.size > 1){ - int count = 0; - for(var near : local){ - //has arrived - no current command, but last one is equal - if(near.isCommandable() && !near.command().hasCommand() && targetPos.epsilonEquals(near.command().lastTargetPos, 0.001f)){ - count ++; - } - } - - //others have arrived at destination, so this one will too - if(count >= Math.max(3, local.size / 2)){ - finishPath(); - } - } + //reached destination, end pathfinding + if(attackTarget == null && unit.within(vecMovePos, Math.max(5f, unit.hitSize / 2f))){ + finishPath(); } - if(stopWhenInRange && targetPos != null && unit.within(targetPos, engageRange * 0.9f)){ + if(stopWhenInRange && targetPos != null && unit.within(vecMovePos, engageRange * 0.9f)){ finishPath(); stopWhenInRange = false; } @@ -292,6 +266,7 @@ public class CommandAI extends AIController{ void finishPath(){ Vec2 prev = targetPos; targetPos = null; + if(commandQueue.size > 0){ var next = commandQueue.remove(0); if(next instanceof Teamc target){ @@ -303,6 +278,10 @@ public class CommandAI extends AIController{ if(prev != null && stance == UnitStance.patrol){ commandQueue.add(prev.cpy()); } + }else{ + if(group != null){ + group = null; + } } } diff --git a/core/src/mindustry/async/PhysicsProcess.java b/core/src/mindustry/async/PhysicsProcess.java index f9d0ab9405..183d126217 100644 --- a/core/src/mindustry/async/PhysicsProcess.java +++ b/core/src/mindustry/async/PhysicsProcess.java @@ -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 refs = new Seq<>(false); @@ -58,9 +58,7 @@ public class PhysicsProcess implements AsyncProcess{ //save last position PhysicRef ref = entity.physref; - ref.body.layer = - entity.type.allowLegStep && entity.type.legPhysicsLayer ? layerLegs : - entity.isGrounded() ? layerGround : layerFlying; + ref.body.layer = entity.collisionLayer(); ref.x = entity.x; ref.y = entity.y; ref.body.local = local || entity.isLocal(); diff --git a/core/src/mindustry/content/Fx.java b/core/src/mindustry/content/Fx.java index 09b7574775..8b622535cc 100644 --- a/core/src/mindustry/content/Fx.java +++ b/core/src/mindustry/content/Fx.java @@ -2558,5 +2558,23 @@ public class Fx{ stroke(data.region.height * scl); line(data.region, data.a.x + ox, data.a.y + oy, data.b.x + ox, data.b.y + oy, false); - }).layer(Layer.groundUnit + 5f); + }).layer(Layer.groundUnit + 5f), + + debugLine = new Effect(90f, 1000000000000f, e -> { + if(!(e.data instanceof Vec2[] vec)) return; + + Draw.color(e.color); + Lines.stroke(1f); + + if(vec.length == 2){ + Lines.line(vec[0].x, vec[0].y, vec[1].x, vec[1].y); + }else{ + Lines.beginLine(); + for(Vec2 v : vec) + Lines.linePoint(v.x, v.y); + Lines.endLine(); + } + + Draw.reset(); + }); } diff --git a/core/src/mindustry/entities/comp/UnitComp.java b/core/src/mindustry/entities/comp/UnitComp.java index 7595a3709f..6ba6e11520 100644 --- a/core/src/mindustry/entities/comp/UnitComp.java +++ b/core/src/mindustry/entities/comp/UnitComp.java @@ -10,6 +10,7 @@ import arc.util.*; import mindustry.ai.*; import mindustry.ai.types.*; import mindustry.annotations.Annotations.*; +import mindustry.async.*; import mindustry.content.*; import mindustry.core.*; import mindustry.ctype.*; @@ -390,6 +391,11 @@ abstract class UnitComp implements Healthc, Physicsc, Hitboxc, Statusc, Teamc, I controller(controller); } + /** @return the collision layer to use for unit physics. Returning anything outside of PhysicsProcess contents will crash the game. */ + public int collisionLayer(){ + return type.allowLegStep && type.legPhysicsLayer ? PhysicsProcess.layerLegs : isGrounded() ? PhysicsProcess.layerGround : PhysicsProcess.layerFlying; + } + /** @return pathfinder path type for calculating costs */ public int pathType(){ return Pathfinder.costGround; diff --git a/core/src/mindustry/input/InputHandler.java b/core/src/mindustry/input/InputHandler.java index fc1f29da3d..a1047b35ad 100644 --- a/core/src/mindustry/input/InputHandler.java +++ b/core/src/mindustry/input/InputHandler.java @@ -17,6 +17,7 @@ import mindustry.*; import mindustry.ai.*; import mindustry.ai.types.*; import mindustry.annotations.Annotations.*; +import mindustry.async.*; import mindustry.content.*; import mindustry.core.*; import mindustry.entities.*; @@ -46,6 +47,9 @@ import static arc.Core.*; import static mindustry.Vars.*; public abstract class InputHandler implements InputProcessor, GestureListener{ + //not sure where else to put this - maps unique commands based on position to a list of units that will be turned into a unit group + static ObjectMap> queuedCommands = new ObjectMap<>(); + /** Used for dropping items. */ final static float playerSelectRange = mobile ? 17f : 11f; final static IntSeq removed = new IntSeq(); @@ -227,7 +231,7 @@ public abstract class InputHandler implements InputProcessor, GestureListener{ } @Remote(called = Loc.server, targets = Loc.both, forward = true) - public static void commandUnits(Player player, int[] unitIds, @Nullable Building buildTarget, @Nullable Unit unitTarget, @Nullable Vec2 posTarget, boolean queueCommand){ + public static void commandUnits(Player player, int[] unitIds, @Nullable Building buildTarget, @Nullable Unit unitTarget, @Nullable Vec2 posTarget, boolean queueCommand, boolean finalBatch){ if(player == null || unitIds == null) return; //why did I ever think this was a good idea @@ -240,6 +244,8 @@ public abstract class InputHandler implements InputProcessor, GestureListener{ } Teamc teamTarget = buildTarget == null ? unitTarget : buildTarget; + Vec2 targetAsVec = new Vec2().set(teamTarget != null ? teamTarget : posTarget); + Seq toAdd = queuedCommands.get(targetAsVec, Seq::new); boolean anyCommandedTarget = false; for(int id : unitIds){ @@ -276,6 +282,37 @@ public abstract class InputHandler implements InputProcessor, GestureListener{ if(!headless && player != Vars.player){ control.input.selectedUnits.remove(unit); } + + toAdd.add(unit); + } + } + + //in the "final batch" of commands, assign formations based on EVERYTHING that was commanded. + if(finalBatch){ + //each physics layer has its own group + UnitGroup[] groups = new UnitGroup[PhysicsProcess.layers]; + var units = queuedCommands.remove(targetAsVec); + + for(Unit unit : units){ + if(unit.controller() instanceof CommandAI ai){ + //only assign a group when this is not a queued command + if(ai.commandQueue.size == 0 && unitIds.length > 1){ + int layer = unit.collisionLayer(); + if(groups[layer] == null){ + groups[layer] = new UnitGroup(); + } + + groups[layer].units.add(unit); + ai.group = groups[layer]; + } + } + } + + for(int i = 0; i < groups.length; i ++){ + var group = groups[i]; + if(group != null && group.units.size > 0){ + group.calculateFormation(targetAsVec, i); + } } } @@ -934,10 +971,10 @@ public abstract class InputHandler implements InputProcessor, GestureListener{ if(ids.length > maxChunkSize){ for(int i = 0; i < ids.length; i += maxChunkSize){ int[] data = Arrays.copyOfRange(ids, i, Math.min(i + maxChunkSize, ids.length)); - Call.commandUnits(player, data, attack instanceof Building b ? b : null, attack instanceof Unit u ? u : null, target, queue); + Call.commandUnits(player, data, attack instanceof Building b ? b : null, attack instanceof Unit u ? u : null, target, queue, i + maxChunkSize >= ids.length); } }else{ - Call.commandUnits(player, ids, attack instanceof Building b ? b : null, attack instanceof Unit u ? u : null, target, queue); + Call.commandUnits(player, ids, attack instanceof Building b ? b : null, attack instanceof Unit u ? u : null, target, queue, true); } } diff --git a/core/src/mindustry/type/weapons/PointDefenseWeapon.java b/core/src/mindustry/type/weapons/PointDefenseWeapon.java index 9063503a13..86c1130dc1 100644 --- a/core/src/mindustry/type/weapons/PointDefenseWeapon.java +++ b/core/src/mindustry/type/weapons/PointDefenseWeapon.java @@ -35,6 +35,7 @@ public class PointDefenseWeapon extends Weapon{ rotate = true; useAmmo = false; useAttackRange = false; + targetInterval = 10f; } @Override