diff --git a/core/assets/bundles/bundle.properties b/core/assets/bundles/bundle.properties index c79859cb0e..47574c1850 100644 --- a/core/assets/bundles/bundle.properties +++ b/core/assets/bundles/bundle.properties @@ -24,7 +24,6 @@ gameover.waiting = [accent]Waiting for next map... highscore = [accent]New highscore! copied = Copied. indev.notready = This part of the game isn't ready yet -indev.campaign = [accent]Congratulations! You've reached the end of the campaign![]\n\nThis is as far as the content goes right now. Interplanetary travel will be added in future updates. load.sound = Sounds load.map = Maps diff --git a/core/src/mindustry/Vars.java b/core/src/mindustry/Vars.java index 4a17a56111..0d35391606 100644 --- a/core/src/mindustry/Vars.java +++ b/core/src/mindustry/Vars.java @@ -232,6 +232,7 @@ public class Vars implements Loadable{ public static WaveSpawner spawner; public static BlockIndexer indexer; public static Pathfinder pathfinder; + public static ControlPathfinder controlPath; public static Control control; public static Logic logic; @@ -298,6 +299,7 @@ public class Vars implements Loadable{ spawner = new WaveSpawner(); indexer = new BlockIndexer(); pathfinder = new Pathfinder(); + controlPath = new ControlPathfinder(); bases = new BaseRegistry(); constants = new GlobalConstants(); javaPath = diff --git a/core/src/mindustry/ai/BaseAI.java b/core/src/mindustry/ai/BaseAI.java index 7b3d0a54ef..9b8ef4164f 100644 --- a/core/src/mindustry/ai/BaseAI.java +++ b/core/src/mindustry/ai/BaseAI.java @@ -96,17 +96,17 @@ public class BaseAI{ var field = pathfinder.getField(data.team, Pathfinder.costGround, Pathfinder.fieldCore); if(field.weights != null){ - int[][] weights = field.weights; + 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; + 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]; + if(other != null && weights[packed] < minCost && weights[packed] != -1){ + minCost = weights[packed]; calcTile = other; foundAny = true; } diff --git a/core/src/mindustry/ai/ControlPathfinder.java b/core/src/mindustry/ai/ControlPathfinder.java index 280c97c065..5b73bb1ee5 100644 --- a/core/src/mindustry/ai/ControlPathfinder.java +++ b/core/src/mindustry/ai/ControlPathfinder.java @@ -1,41 +1,125 @@ package mindustry.ai; import arc.*; +import arc.graphics.*; +import arc.graphics.g2d.*; import arc.math.geom.*; import arc.struct.*; import arc.util.*; import arc.util.async.*; +import mindustry.core.*; import mindustry.game.EventType.*; import mindustry.gen.*; +import mindustry.graphics.*; import mindustry.world.*; import static mindustry.Vars.*; +import static mindustry.ai.Pathfinder.*; +//TODO I'm sure this class has countless problems public class ControlPathfinder implements Runnable{ - private static final long maxUpdate = Time.millisToNanos(7); - private static final int updateFPS = 60; + private static final long maxUpdate = Time.millisToNanos(20); + private static final int updateFPS = 40; private static final int updateInterval = 1000 / updateFPS; + public static boolean showDebug = false; + + public static final Seq costTypes = Seq.with( + //ground + (team, tile) -> (PathTile.allDeep(tile) || PathTile.solid(tile)) ? impassable : 1 + + (PathTile.nearSolid(tile) ? 6 : 0) + + (PathTile.nearLiquid(tile) ? 8 : 0) + + (PathTile.deep(tile) ? 6000 : 0) + + (PathTile.damages(tile) ? 40 : 0), + + //legs + (team, tile) -> PathTile.legSolid(tile) ? impassable : 1 + + (PathTile.deep(tile) ? 6000 : 0) + + (PathTile.nearSolid(tile) || PathTile.solid(tile) ? 3 : 0), + + //water + (team, tile) -> (PathTile.solid(tile) || !PathTile.liquid(tile) ? impassable : 1) + + (PathTile.nearGround(tile) || PathTile.nearSolid(tile) ? 2 : 0) + + (PathTile.deep(tile) ? 0 : 1) + ); + + //static access probably faster than object access + static int wwidth, wheight; + //MAIN THREAD DATA + + static volatile int worldUpdateId; + /** Current pathfinding thread */ @Nullable Thread thread; /** for unique target IDs */ - int lastTargetId; + int lastTargetId = 1; /** handles task scheduling on the update thread. */ TaskQueue queue = new TaskQueue(); + ObjectMap requests = new ObjectMap<>(); //PATHFINDING THREAD DATA - ObjectMap requests = new ObjectMap<>(); + Seq threadRequests = new Seq<>(); public ControlPathfinder(){ Events.on(WorldLoadEvent.class, event -> { stop(); - + wwidth = world.width(); + wheight = world.height(); start(); }); + Events.on(TileChangeEvent.class, e -> { + worldUpdateId ++; + }); + Events.on(ResetEvent.class, event -> stop()); + + //invalidate paths + Events.run(Trigger.update, () -> { + for(var req : requests.values()){ + //skipped N update -> drop it + if(req.lastUpdateId <= state.updateId - 10){ + requests.remove(req.unit); + queue.post(() -> threadRequests.remove(req)); + } + } + }); + + Events.run(Trigger.draw, () -> { + if(!showDebug) return; + + for(var req : requests.values()){ + if(req.frontier == null) continue; + //TODO will this even work + Draw.draw(Layer.overlayUI, () -> { + if(req.done){ + int len = req.result.size; + for(int i = 0; i < len; i++){ + Draw.color(Tmp.c1.set(Color.white).fromHsv(i / (float)len * 360f, 1f, 0.9f)); + int pos = req.result.items[i]; + Fill.square(pos % wwidth * tilesize, pos / wwidth * tilesize, 3f); + + if(i == req.pathIndex){ + Draw.color(Color.green); + Lines.square(pos % wwidth * tilesize, pos / wwidth * tilesize, 5f); + } + } + }else{ + int len = req.frontier.size; + float[] weights = req.frontier.weights; + int[] poses = req.frontier.queue; + for(int i = 0; i < len; i++){ + Draw.color(Tmp.c1.set(Color.white).fromHsv((weights[i] * 4f) % 360f, 1f, 0.9f)); + int pos = poses[i]; + Lines.square(pos % wwidth * tilesize, pos / wwidth * tilesize, 4f); + } + } + Draw.reset(); + }); + } + }); } @@ -44,10 +128,104 @@ public class ControlPathfinder implements Runnable{ return lastTargetId ++; } - public void getPathPosition(Unit unit, int pathId, Vec2 destination){ + /** @return whether a path is ready */ + public boolean getPathPosition(Unit unit, int pathId, Vec2 destination, Vec2 out){ + int pathType = unit.pathType(); + + //if the destination can be trivially reached in a straight line, do that. + if((!requests.containsKey(unit) || requests.get(unit).curId != pathId) && !raycast(pathType, unit.tileX(), unit.tileY(), World.toTile(destination.x), World.toTile(destination.y))){ + out.set(destination); + return true; + } + + //destination is impassable, can't go there. + if(solid(pathType, world.packArray(World.toTile(destination.x), World.toTile(destination.y)))){ + return false; + } + + //check for request existence + if(!requests.containsKey(unit)){ + var req = new PathRequest(); + req.unit = unit; + req.pathType = pathType; + req.destination = destination; + req.curId = pathId; + req.lastUpdateId = state.updateId; + req.lastWorldUpdate = worldUpdateId; + + requests.put(unit, req); + + //add to thread so it gets processed next update + queue.post(() -> threadRequests.add(req)); + }else{ + var req = requests.get(unit); + req.lastUpdateId = state.updateId; + if(req.curId != req.lastId || req.curId != pathId){ + req.pathIndex = 0; + req.rayPathIndex = -1; + req.done = false; + req.foundEnd = false; + } + + req.destination = destination; + req.curId = pathId; + + if(req.done){ + int[] items = req.result.items; + int len = req.result.size; + int tileX = unit.tileX(), tileY = unit.tileY(); + float range = 4f; + + float minDst = req.pathIndex < len ? unit.dst2(world.tiles.geti(items[req.pathIndex])) : 0f; + int idx = req.pathIndex; + + //find closest node that is in front of the path index and hittable with raycast + for(int i = len - 1; i >= idx; i--){ + Tile tile = tile(items[i]); + float dst = unit.dst2(tile); + if(dst < minDst && !permissiveRaycast(pathType, tileX, tileY, tile.x, tile.y)){ + req.pathIndex = Math.max(dst <= range * range ? i + 1 : i, req.pathIndex); + minDst = Math.min(dst, minDst); + } + } + + if(req.rayPathIndex < 0){ + req.rayPathIndex = req.pathIndex; + } + + //TODO indecision dance: moving forward blocks the raycasted node from view, so it moves back. + if((req.raycastTimer += Time.delta) >= 50f){ + for(int i = len - 1; i > req.pathIndex; i--){ + int val = items[i]; + //TODO this raycasting is flawed, it assumes units can move through corners even when they can't. + if(!raycast(pathType, tileX, tileY, val % wwidth, val / wwidth)){ + req.rayPathIndex = i; + break; + } + } + req.raycastTimer = 0; + } + + if(req.rayPathIndex < len){ + Tile tile = tile(items[req.rayPathIndex]); + out.set(tile); + + if(unit.within(tile, range)){ + req.pathIndex = req.rayPathIndex = Math.max(req.pathIndex, req.rayPathIndex + 1); + } + }else{ + //implicit done + out.set(unit); + //end of path, we're done here? reset path? what??? + } + } + + return req.done; + } + + return false; } - /** Starts or restarts the pathfinding thread. */ private void start(){ stop(); @@ -63,30 +241,110 @@ public class ControlPathfinder implements Runnable{ requests.clear(); } - //distance heuristic: manhattan - private static float dstCost(float x, float y, float x2, float y2){ - return Math.abs(x - x2) + Math.abs(x2 - y2); + private static boolean raycast(int 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 e2, err = dx - dy; + + while(x >= 0 && y >= 0 && x < ww && y < wh){ + if(avoid(type, x + y * wwidth)) return true; + if(x == x2 && y == y2) return false; + + e2 = 2 * err; + if(e2 > -dy){ + err -= dy; + x += sx; + } + + if(e2 < dx){ + err += dx; + y += sy; + } + + + //no diagonals allowed here, mimics how units actually move + /* + if(2 * err + dy > dx - 2 * err){ + err -= dy; + x += sx; + }else{ + err += dx; + y += sy; + }*/ + + } + + return true; } - private static float tileCost(Tile a, Tile b){ - return 1f; + private static boolean permissiveRaycast(int 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 e2, err = dx - dy; + + while(x >= 0 && y >= 0 && x < ww && y < wh){ + if(solid(type, x + y * wwidth)) return true; + if(x == x2 && y == y2) return false; + + //no diagonals + if(2 * err + dy > dx - 2 * err){ + err -= dy; + x += sx; + }else{ + err += dx; + y += sy; + } + } + + return true; + } + + static boolean cast(int pathType, int from, int to){ + return raycast(pathType, from % wwidth, from / wwidth, to % wwidth, to / wwidth); + } + + private Tile tile(int pos){ + return world.tiles.geti(pos); + } + + //distance heuristic: manhattan + private static float heuristic(int a, int b){ + int x = a % wwidth, x2 = b % wwidth, y = a / wwidth, y2 = b / wwidth; + return Math.abs(x - x2) + Math.abs(y - y2); + } + + private static int cost(int type, int tilePos){ + return costTypes.items[type].getCost(null, pathfinder.tiles[tilePos]); + } + + private static boolean avoid(int type, int tilePos){ + int cost = cost(type, tilePos); + return cost == impassable || cost >= 2; + } + + private static boolean solid(int type, int tilePos){ + return cost(type, tilePos) == impassable; + } + + private static float tileCost(int type, int a, int b){ + //currently flat cost + return cost(type, b); } @Override public void run(){ while(true){ + //stop on client, no updating if(net.client()) return; try{ if(state.isPlaying()){ queue.run(); //total update time no longer than maxUpdate - //for(Flowfield data : threadList){ - // updateFrontier(data, maxUpdate / threadList.size); - //} - - for(var entry : requests){ - entry.value.update(maxUpdate / requests.size); + for(var req : threadRequests){ + req.update(maxUpdate / requests.size); } } @@ -103,35 +361,159 @@ public class ControlPathfinder implements Runnable{ } } - class PathRequest{ - GridBits closed; - PQueue queue; + //TODO each one of these could run in its own thread. + static class PathRequest{ + volatile boolean done = false; + volatile boolean foundEnd = false; - //TODO how will costs be computed? where will they be stored...? + volatile Unit unit; + volatile int pathType; + volatile Vec2 destination; + volatile int lastWorldUpdate; + volatile float stuckTime; - int lastId; - int curId; - int lastFrame; + //TODO only access on main thread?? + int pathIndex; + int rayPathIndex = -1; + IntSeq result = new IntSeq(); + float raycastTimer; - PathRequest(){ - clear(); + PathfindQueue frontier = new PathfindQueue(); + //node index -> node it came from + IntIntMap cameFrom = new IntIntMap(); + //node index -> total cost + IntFloatMap costs = new IntFloatMap(); - lastId = curId; - } + int start, goal; + + long lastTime; + + volatile int lastId, curId; + + //TODO invalidate when not request for a while + long lastUpdateId; void update(long maxUpdateNs){ if(curId != lastId){ clear(); } + lastId = curId; + //re-do everything when world updates + if(Time.timeSinceMillis(lastTime) > 1000 * 1 && worldUpdateId != lastWorldUpdate){ + lastTime = Time.millis(); + lastWorldUpdate = worldUpdateId; + pathIndex = 0; + rayPathIndex = -1; + result.clear(); + clear(); + } + if(done) return; + + long ns = Time.nanos(); + int counter = 0; + //Log.info("running; @ in frontier", frontier.size); + + while(frontier.size > 0){ + int current = frontier.poll(); + + if(current == goal){ + foundEnd = true; + break; + } + + int cx = current % wwidth, cy = current / wwidth; + + //TODO corner traps? d8 vs d4 here? + for(Point2 point : Geometry.d4){ + int newx = cx + point.x, newy = cy + point.y; + int next = newx + wwidth * newy; + + if(newx >= wwidth || newy >= wheight || newx < 0 || newy < 0) continue; + + if(cost(pathType, next) == impassable) continue; + + float newCost = costs.get(current) + tileCost(pathType, current, next); + //a cost of 0 means "not set" + if(!costs.containsKey(next) || newCost < costs.get(next)){ + costs.put(next, newCost); + float priority = newCost + heuristic(next, goal); + frontier.add(next, priority); + cameFrom.put(next, current); + } + } + + //only check every N iterations to prevent nanoTime spam (slow) + if((counter ++) >= 100){ + counter = 0; + + //exit when out of time. + if(Time.timeSinceNanos(ns) > maxUpdateNs){ + return; + } + } + } + + lastTime = Time.millis(); + result.clear(); + + if(foundEnd){ + int cur = goal; + while(cur != start){ + result.add(cur); + cur = cameFrom.get(cur); + } + + result.reverse(); + + smoothPath(); + } + + //TODO free resources? + + done = true; + } + + void smoothPath(){ + int len = result.size; + if(len <= 2) return; + + int output = 1, input = 2; + + while(input < len){ + if(cast(pathType, result.get(output - 1), result.get(input))){ + result.swap(output, input - 1); + output++; + } + input++; + } + + result.swap(output, input - 1); + result.size = output + 1; } void clear(){ - //TODO + done = false; - closed = new GridBits(world.width(), world.height()); - queue = new PQueue<>(16, (a, b) -> 0); + //TODO horribly expensive + frontier = new PathfindQueue(20); + cameFrom.clear(); + costs.clear(); + + start = world.packArray(unit.tileX(), unit.tileY()); + goal = world.packArray(World.toTile(destination.x), World.toTile(destination.y)); + + cameFrom.put(start, start); + costs.put(start, 0); + + frontier.add(start, 0); + + foundEnd = false; + result.clear(); + + //closed = new GridBits(world.width(), world.height()); + //queue = new PathfindQueue(16); } } } diff --git a/core/src/mindustry/ai/PathfindQueue.java b/core/src/mindustry/ai/PathfindQueue.java new file mode 100644 index 0000000000..aab2d7df82 --- /dev/null +++ b/core/src/mindustry/ai/PathfindQueue.java @@ -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; + } +} \ No newline at end of file diff --git a/core/src/mindustry/ai/Pathfinder.java b/core/src/mindustry/ai/Pathfinder.java index 18fb9a6aff..e5f3f155e5 100644 --- a/core/src/mindustry/ai/Pathfinder.java +++ b/core/src/mindustry/ai/Pathfinder.java @@ -23,7 +23,8 @@ public class Pathfinder implements Runnable{ private static final long maxUpdate = Time.millisToNanos(7); private static final int updateFPS = 60; private static final int updateInterval = 1000 / updateFPS; - private static final int impassable = -1; + + static final int impassable = -1; public static final int fieldCore = 0, @@ -60,11 +61,13 @@ public class Pathfinder implements Runnable{ (PathTile.damages(tile) ? 35 : 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; + /** cached world size */ + int wwidth, wheight; /** unordered array of path data for iteration only. DO NOT iterate or access this in the main thread. */ Seq threadList = new Seq<>(), mainList = new Seq<>(); /** handles task scheduling on the update thread. */ @@ -80,13 +83,16 @@ 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)); @@ -163,8 +169,9 @@ public class Pathfinder implements Runnable{ 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); } }); @@ -208,7 +215,9 @@ public class Pathfinder implements Runnable{ return; } }catch(Throwable e){ - e.printStackTrace(); + //TODO remove in production! + Threads.throwAppException(e); + //e.printStackTrace(); } } } @@ -255,8 +264,9 @@ public class Pathfinder implements Runnable{ } } - int[][] values = path.weights; - int value = values[tile.x][tile.y]; + int[] values = path.weights; + int apos = tile.array(); + int value = values[apos]; Tile current = null; int tl = 0; @@ -266,10 +276,12 @@ 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]; } } @@ -283,9 +295,11 @@ public class Pathfinder implements Runnable{ * This only occurs for active teams. */ private void updateTargets(Flowfield path, int x, int y){ - if(!Structs.inBounds(x, y, path.weights)) return; + int packed = world.packArray(x, y); - if(path.weights[x][y] == 0){ + if(packed > path.weights.length) return; + + if(path.weights[packed] == 0){ //this was a previous target path.frontier.clear(); }else if(!path.frontier.isEmpty()){ @@ -294,7 +308,7 @@ public class Pathfinder implements Runnable{ } //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]); + path.weights[packed] = path.cost.getCost(path.team, tiles[packed]); //clear frontier to prevent contamination path.frontier.clear(); @@ -312,10 +326,9 @@ 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); } } @@ -335,7 +348,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); @@ -343,16 +356,14 @@ 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); } } @@ -361,10 +372,12 @@ public class Pathfinder implements Runnable{ private void updateFrontier(Flowfield path, long nsToRun){ 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()){ @@ -375,19 +388,28 @@ 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 / wheight) + 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, 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 100 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; + } + } } } @@ -395,13 +417,13 @@ public class Pathfinder implements Runnable{ @Override protected void getPositions(IntSeq out){ for(Building other : indexer.getEnemy(team, BlockFlag.core)){ - out.add(other.pos()); + 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()); } } } @@ -411,7 +433,7 @@ public class Pathfinder implements Runnable{ @Override protected void getPositions(IntSeq out){ for(Building other : indexer.getFlagged(team, BlockFlag.rally)){ - out.add(other.pos()); + out.add(other.tile.array()); } } } @@ -426,7 +448,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()))); } } @@ -443,9 +465,9 @@ public class Pathfinder implements Runnable{ protected PathCost cost = costTypes.get(costGround); /** 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; /** 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! */ @@ -457,15 +479,15 @@ 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.frontier.ensureCapacity((length) / 4); this.initialized = true; } - protected boolean passable(int x, int y){ - return cost.getCost(team, pathfinder.tiles[x][y]) != impassable; + protected boolean passable(int pos){ + return cost.getCost(team, pathfinder.tiles[pos]) != impassable; } /** Gets targets to pathfind towards. This must run on the main thread. */ diff --git a/core/src/mindustry/ai/types/CommandAI.java b/core/src/mindustry/ai/types/CommandAI.java index 7bafe3ad4e..bcfe25fc16 100644 --- a/core/src/mindustry/ai/types/CommandAI.java +++ b/core/src/mindustry/ai/types/CommandAI.java @@ -2,25 +2,67 @@ package mindustry.ai.types; import arc.math.geom.*; import arc.util.*; +import mindustry.*; +import mindustry.ai.*; import mindustry.entities.units.*; import mindustry.gen.*; +import mindustry.world.*; public class CommandAI extends AIController{ + static Vec2 vecOut = new Vec2(); + public @Nullable Vec2 targetPos; public @Nullable Teamc attackTarget; + private int pathId = -1; + @Override public void updateUnit(){ updateVisuals(); updateTargeting(); + if(invalid(attackTarget)){ + attackTarget = null; + targetPos = null; + } + if(attackTarget != null){ if(targetPos == null) targetPos = new Vec2(); targetPos.set(attackTarget); + + if(unit.isGrounded() && attackTarget instanceof Building build && build.tile.solid() && unit.pathType() != Pathfinder.costLegs){ + Tile best = null; + float mindst = 0f; + for(var point : Edges.getEdges(build.block.size)){ + Tile tile = Vars.world.tile(build.tile.x + point.x, build.tile.y + point.y); + if(tile != null && !tile.solid() && (best == null || unit.dst2(tile) < mindst)){ + best = tile; + mindst = unit.dst2(tile); + } + } + if(best != null){ + targetPos.set(best); + } + } } if(targetPos != null){ - moveTo(targetPos, attackTarget != null ? unit.type.range - 10f : 0f); + boolean move = true; + vecOut.set(targetPos); + + if(unit.isGrounded()){ + move = Vars.controlPath.getPathPosition(unit, pathId, targetPos, vecOut); + } + + float engageRange = unit.type.range - 10f; + + if(move){ + moveTo(vecOut, + attackTarget != null && unit.within(attackTarget, engageRange) ? engageRange : + unit.isGrounded() ? 0f : + attackTarget != null ? engageRange : + 0f, 100f, false); + } if(unit.isFlying()){ unit.lookAt(targetPos); @@ -28,7 +70,7 @@ public class CommandAI extends AIController{ faceTarget(); } - if(attackTarget == null && unit.within(targetPos, Math.max(5f, unit.hitSize) / 2.9f)){ + if(attackTarget == null && unit.within(targetPos, Math.max(5f, unit.hitSize) / 2.5f)){ targetPos = null; } }else if(target != null){ @@ -37,17 +79,25 @@ public class CommandAI extends AIController{ } @Override - public Teamc findMainTarget(float x, float y, float range, boolean air, boolean ground){ - return attackTarget == null ? super.findMainTarget(x, y, range, air, ground) : attackTarget; + public Teamc findTarget(float x, float y, float range, boolean air, boolean ground){ + return attackTarget == null ? super.findTarget(x, y, range, air, ground) : attackTarget; + } + + @Override + public boolean retarget(){ + //retarget instantly when there is an explicit target, there is no performance cost + return attackTarget != null || timer.get(timerTarget, 20); } public void commandPosition(Vec2 pos){ targetPos = pos; attackTarget = null; + pathId = Vars.controlPath.nextTargetId(); } public void commandTarget(Teamc moveTo){ //TODO attackTarget = moveTo; + pathId = Vars.controlPath.nextTargetId(); } } diff --git a/core/src/mindustry/content/UnitTypes.java b/core/src/mindustry/content/UnitTypes.java index 7f103a59ba..3d5cd12396 100644 --- a/core/src/mindustry/content/UnitTypes.java +++ b/core/src/mindustry/content/UnitTypes.java @@ -1846,6 +1846,7 @@ public class UnitTypes{ x = y = shootX = shootY = 0f; shootSound = Sounds.mineDeploy; rotateSpeed = 180f; + targetAir = false; bullet = new BasicBulletType(){{ sprite = "mine-bullet"; diff --git a/core/src/mindustry/core/GameState.java b/core/src/mindustry/core/GameState.java index 8fa8d2e635..ecd3a0e253 100644 --- a/core/src/mindustry/core/GameState.java +++ b/core/src/mindustry/core/GameState.java @@ -18,6 +18,8 @@ public class GameState{ 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; /** Server ticks/second. Only valid in multiplayer. */ diff --git a/core/src/mindustry/core/Logic.java b/core/src/mindustry/core/Logic.java index 06a14a63b6..5b0df10d41 100644 --- a/core/src/mindustry/core/Logic.java +++ b/core/src/mindustry/core/Logic.java @@ -397,7 +397,7 @@ public class Logic implements ApplicationListener{ if(!state.isPaused()){ float delta = Core.graphics.getDeltaTime(); state.tick += Float.isNaN(delta) || Float.isInfinite(delta) ? 0f : delta * 60f; - + state.updateId ++; state.teams.updateTeamStats(); if(state.isCampaign()){ diff --git a/core/src/mindustry/core/World.java b/core/src/mindustry/core/World.java index 835d6d8360..199d7c8195 100644 --- a/core/src/mindustry/core/World.java +++ b/core/src/mindustry/core/World.java @@ -170,6 +170,10 @@ public class World{ return Math.round(coord / tilesize); } + public int packArray(int x, int y){ + return x + y * tiles.width; + } + private void clearTileEntities(){ for(Tile tile : tiles){ if(tile != null && tile.build != null){ @@ -373,52 +377,46 @@ public class World{ 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, dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; - int y0 = y0f, dy = Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; + public 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(x0, y0)) break; - if(x0 == x1 && y0 == y1) break; + if(cons.accept(x, y)) break; + if(x == x2 && y == y2) break; e2 = 2 * err; if(e2 > -dy){ err -= dy; - x0 += sx; + x += sx; } if(e2 < dx){ err += dx; - y0 += sy; + y += 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); + public 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; - 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; + if(cons.accept(x, y)) return true; + if(x == x2 && y == y2) return false; e2 = 2 * err; if(e2 > -dy){ err = err - dy; - x0 = x0 + sx; + x = x + sx; } if(e2 < dx){ err = err + dx; - y0 = y0 + sy; + y = y + sy; } } } diff --git a/core/src/mindustry/entities/units/AIController.java b/core/src/mindustry/entities/units/AIController.java index a6833b70c2..1bd6163465 100644 --- a/core/src/mindustry/entities/units/AIController.java +++ b/core/src/mindustry/entities/units/AIController.java @@ -248,6 +248,10 @@ public class AIController implements UnitController{ } public void moveTo(Position target, float circleLength, float smooth){ + moveTo(target, circleLength, smooth, unit.isFlying()); + } + + public void moveTo(Position target, float circleLength, float smooth, boolean keepDistance){ if(target == null) return; vec.set(target).sub(unit); @@ -256,7 +260,11 @@ public class AIController implements UnitController{ vec.setLength(unit.speed() * length); if(length < -0.5f){ - vec.rotate(180f); + if(keepDistance){ + vec.rotate(180f); + }else{ + vec.setZero(); + } }else if(length < 0){ vec.setZero(); } @@ -264,7 +272,15 @@ public class AIController implements UnitController{ //do not move when infinite vectors are used. if(vec.isNaN() || vec.isInfinite()) return; - unit.movePref(vec); + if(!unit.type.omniMovement && unit.type.rotateMoveFirst){ + float angle = vec.angle(); + unit.lookAt(angle); + if(Angles.within(unit.rotation, angle, 3f)){ + unit.movePref(vec); + } + }else{ + unit.movePref(vec); + } } @Override diff --git a/core/src/mindustry/input/DesktopInput.java b/core/src/mindustry/input/DesktopInput.java index 82f1dcada8..80803cff07 100644 --- a/core/src/mindustry/input/DesktopInput.java +++ b/core/src/mindustry/input/DesktopInput.java @@ -14,7 +14,6 @@ import arc.scene.ui.layout.*; import arc.util.*; import mindustry.*; import mindustry.ai.types.*; -import mindustry.content.*; import mindustry.core.*; import mindustry.entities.units.*; import mindustry.game.EventType.*; @@ -122,14 +121,19 @@ public class DesktopInput extends InputHandler{ CommandAI ai = (CommandAI)unit.controller(); //draw target line if(ai.targetPos != null){ - Tmp.v1.set(ai.targetPos).sub(unit).setLength(unit.hitSize / 2f); + Position lineDest = ai.attackTarget != null ? ai.attackTarget : ai.targetPos; - Drawf.line(Pal.accent, unit.x + Tmp.v1.x, unit.y + Tmp.v1.y, ai.targetPos.x, ai.targetPos.y); + Tmp.v1.set(lineDest).sub(unit).setLength(unit.hitSize / 2f); + + Drawf.line(Pal.accent, unit.x + Tmp.v1.x, unit.y + Tmp.v1.y, lineDest.getX(), lineDest.getY()); } Drawf.square(unit.x, unit.y, unit.hitSize / 1.4f + 1f); - } + if(ai.attackTarget != null){ + Drawf.target(ai.attackTarget.getX(), ai.attackTarget.getY(), 6f, Pal.remove); + } + } if(commandMode && !commandRect){ Unit sel = selectedCommandUnit(input.mouseWorldX(), input.mouseWorldY()); @@ -689,29 +693,20 @@ public class DesktopInput extends InputHandler{ } }else if(selectedUnits.size > 0){ //move to location - TODO right click instead? - //TODO all this needs to be synced, done with packets, etc Vec2 target = input.mouseWorld().cpy(); - Teamc build = world.buildWorld(target.x, target.y); + Teamc attack = world.buildWorld(target.x, target.y); - if(build == null || build.team() == player.team()){ - build = selectedEnemyUnit(target.x, target.y); + if(attack == null || attack.team() == player.team()){ + attack = selectedEnemyUnit(target.x, target.y); } - if(build != null && build.team() != player.team()){ - for(var sel : selectedUnits){ - ((CommandAI)sel.controller()).commandTarget(build); - } - - Fx.attackCommand.at(build); - }else{ - for(var sel : selectedUnits){ - ((CommandAI)sel.controller()).commandPosition(target); - } - - Fx.moveCommand.at(target); + int[] ids = new int[selectedUnits.size]; + for(int i = 0; i < ids.length; i++){ + ids[i] = selectedUnits.get(i).id; } + Call.commandUnits(player, ids, attack instanceof Building b ? b : null, attack instanceof Unit u ? u : null, target); } diff --git a/core/src/mindustry/input/InputHandler.java b/core/src/mindustry/input/InputHandler.java index 447823b0b9..aa2fa6d297 100644 --- a/core/src/mindustry/input/InputHandler.java +++ b/core/src/mindustry/input/InputHandler.java @@ -14,6 +14,7 @@ import arc.scene.ui.layout.*; import arc.struct.*; import arc.util.*; import mindustry.ai.formations.patterns.*; +import mindustry.ai.types.*; import mindustry.annotations.Annotations.*; import mindustry.content.*; import mindustry.core.*; @@ -189,6 +190,36 @@ public abstract class InputHandler implements InputProcessor, GestureListener{ } } + @Remote(called = Loc.both, targets = Loc.both, forward = true) + public static void commandUnits(Player player, int[] unitIds, @Nullable Building buildTarget, @Nullable Unit unitTarget, @Nullable Vec2 posTarget){ + if(player == null || unitIds == null) return; + + if(net.server() && !netServer.admins.allowAction(player, ActionType.commandUnits, event -> { + event.unitIDs = unitIds; + })){ + throw new ValidateException(player, "Player cannot command units."); + } + + Teamc teamTarget = buildTarget == null ? unitTarget : buildTarget; + + for(int id : unitIds){ + Unit unit = Groups.unit.getByID(id); + if(unit.team == player.team() && unit.controller() instanceof CommandAI ai){ + if(teamTarget != null && teamTarget.team() != player.team()){ + ai.commandTarget(teamTarget); + }else if(posTarget != null){ + ai.commandPosition(posTarget); + } + } + + if(teamTarget != null){ + Fx.attackCommand.at(teamTarget); + }else{ + Fx.moveCommand.at(posTarget); + } + } + } + @Remote(called = Loc.server, targets = Loc.both, forward = true) public static void requestItem(Player player, Building build, Item item, int amount){ if(player == null || build == null || !build.interactable(player.team()) || !player.within(build, buildingRange) || player.dead()) return; diff --git a/core/src/mindustry/io/TypeIO.java b/core/src/mindustry/io/TypeIO.java index 0222f9d08e..ab0776852e 100644 --- a/core/src/mindustry/io/TypeIO.java +++ b/core/src/mindustry/io/TypeIO.java @@ -391,6 +391,23 @@ public class TypeIO{ }else if(control instanceof LogicAI logic && logic.controller != null){ write.b(3); write.i(logic.controller.pos()); + }else if(control instanceof CommandAI ai){ + write.b(4); + write.bool(ai.attackTarget != null); + write.bool(ai.targetPos != null); + + if(ai.targetPos != null){ + write.f(ai.targetPos.x); + write.f(ai.targetPos.y); + } + if(ai.attackTarget != null){ + write.b(ai.attackTarget instanceof Building ? 1 : 0); + if(ai.attackTarget instanceof Building b){ + write.i(b.pos()); + }else{ + write.i(((Unit)ai.attackTarget).id); + } + } }else{ write.b(2); } @@ -424,6 +441,29 @@ public class TypeIO{ out.controller = world.build(pos); return out; } + }else if(type == 4){ + CommandAI ai = prev instanceof CommandAI pai ? pai : new CommandAI(); + + boolean hasAttack = read.bool(), hasPos = read.bool(); + if(hasPos){ + if(ai.targetPos == null) ai.targetPos = new Vec2(); + ai.targetPos.set(read.f(), read.f()); + }else{ + ai.targetPos = null; + } + + if(hasAttack){ + byte entityType = read.b(); + if(entityType == 1){ + ai.attackTarget = world.build(read.i()); + }else{ + ai.attackTarget = Groups.unit.getByID(read.i()); + } + }else{ + ai.attackTarget = null; + } + + return ai; }else{ //there are two cases here: //1: prev controller was not a player, carry on diff --git a/core/src/mindustry/logic/LExecutor.java b/core/src/mindustry/logic/LExecutor.java index fe60691af9..b58aff8d54 100644 --- a/core/src/mindustry/logic/LExecutor.java +++ b/core/src/mindustry/logic/LExecutor.java @@ -1,6 +1,5 @@ package mindustry.logic; -import arc.*; import arc.math.*; import arc.math.geom.*; import arc.struct.*; @@ -1055,9 +1054,9 @@ public class LExecutor{ exec.var(varCounter).numval --; } - if(Core.graphics.getFrameId() != frameId){ + if(state.updateId != frameId){ curTime += Time.delta / 60f; - frameId = Core.graphics.getFrameId(); + frameId = state.updateId; } } } diff --git a/core/src/mindustry/maps/SectorDamage.java b/core/src/mindustry/maps/SectorDamage.java index 5a8d4817cc..a37e153f95 100644 --- a/core/src/mindustry/maps/SectorDamage.java +++ b/core/src/mindustry/maps/SectorDamage.java @@ -200,18 +200,18 @@ public class SectorDamage{ boolean found = false; if(field != null && field.weights != null){ - int[][] weights = field.weights; + int[] weights = field.weights; int count = 0; Tile current = start; - while(count < world.width() * world.height()){ + while(count < weights.length){ int minCost = Integer.MAX_VALUE; int cx = current.x, cy = current.y; for(Point2 p : Geometry.d4){ - int nx = cx + p.x, ny = cy + p.y; + 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]; + if(other != null && weights[packed] < minCost && weights[packed] != -1){ + minCost = weights[packed]; current = other; } } diff --git a/core/src/mindustry/net/Administration.java b/core/src/mindustry/net/Administration.java index 68736ffd37..047b297093 100644 --- a/core/src/mindustry/net/Administration.java +++ b/core/src/mindustry/net/Administration.java @@ -621,6 +621,9 @@ public class Administration{ /** valid only for removePlanned events only; contains packed positions. */ public @Nullable int[] plans; + /** valid only for command unit events */ + public @Nullable int[] unitIDs; + public PlayerAction set(Player player, ActionType type, Tile tile){ this.player = player; this.type = type; @@ -650,7 +653,7 @@ public class Administration{ } public enum ActionType{ - breakBlock, placeBlock, rotate, configure, withdrawItem, depositItem, control, buildSelect, command, removePlanned + breakBlock, placeBlock, rotate, configure, withdrawItem, depositItem, control, buildSelect, command, removePlanned, commandUnits } } diff --git a/core/src/mindustry/type/UnitType.java b/core/src/mindustry/type/UnitType.java index f667ba50c6..55e3485739 100644 --- a/core/src/mindustry/type/UnitType.java +++ b/core/src/mindustry/type/UnitType.java @@ -99,6 +99,8 @@ public class UnitType extends UnlockableContent{ /** If true, this unit cannot drown, and will not be affected by the floor under it. */ public boolean hovering = false; public boolean omniMovement = true; + /** If true, the unit faces its moving direction before actually moving. */ + public boolean rotateMoveFirst = false; public boolean showHeal = true; public Color healColor = Pal.heal; public Effect fallEffect = Fx.fallSmoke; diff --git a/core/src/mindustry/type/unit/TankUnitType.java b/core/src/mindustry/type/unit/TankUnitType.java index aa942b2092..feb5cec6d4 100644 --- a/core/src/mindustry/type/unit/TankUnitType.java +++ b/core/src/mindustry/type/unit/TankUnitType.java @@ -9,6 +9,7 @@ public class TankUnitType extends ErekirUnitType{ squareShape = true; omniMovement = false; + rotateMoveFirst = true; rotateSpeed = 1.3f; envDisabled = Env.none; speed = 0.8f; diff --git a/core/src/mindustry/world/blocks/defense/RegenProjector.java b/core/src/mindustry/world/blocks/defense/RegenProjector.java index 7885a17710..b044c0191b 100644 --- a/core/src/mindustry/world/blocks/defense/RegenProjector.java +++ b/core/src/mindustry/world/blocks/defense/RegenProjector.java @@ -1,6 +1,5 @@ package mindustry.world.blocks.defense; -import arc.*; import arc.graphics.*; import arc.graphics.g2d.*; import arc.math.*; @@ -147,8 +146,8 @@ public class RegenProjector extends Block{ } } - if(lastUpdateFrame != Core.graphics.getFrameId()){ - lastUpdateFrame = Core.graphics.getFrameId(); + if(lastUpdateFrame != state.updateId){ + lastUpdateFrame = state.updateId; for(var entry : mendMap.entries()){ var build = world.build(entry.key); diff --git a/core/src/mindustry/world/blocks/payloads/BlockProducer.java b/core/src/mindustry/world/blocks/payloads/BlockProducer.java index c51c32f1bf..0796775b56 100644 --- a/core/src/mindustry/world/blocks/payloads/BlockProducer.java +++ b/core/src/mindustry/world/blocks/payloads/BlockProducer.java @@ -28,6 +28,7 @@ public abstract class BlockProducer extends PayloadBlock{ update = true; outputsPayload = true; hasItems = true; + solid = true; hasPower = true; rotate = true; regionRotated1 = 1; diff --git a/core/src/mindustry/world/blocks/power/PowerGraph.java b/core/src/mindustry/world/blocks/power/PowerGraph.java index 33f552f186..2f16ce50a0 100644 --- a/core/src/mindustry/world/blocks/power/PowerGraph.java +++ b/core/src/mindustry/world/blocks/power/PowerGraph.java @@ -1,12 +1,13 @@ package mindustry.world.blocks.power; -import arc.*; import arc.math.*; import arc.struct.*; import arc.util.*; import mindustry.gen.*; import mindustry.world.consumers.*; +import static mindustry.Vars.*; + public class PowerGraph{ private static final Queue queue = new Queue<>(); private static final Seq outArray1 = new Seq<>(); @@ -206,7 +207,7 @@ public class PowerGraph{ } public void update(){ - if(Core.graphics.getFrameId() == lastFrameUpdated){ + if(state.updateId == lastFrameUpdated){ return; }else if(!consumers.isEmpty() && consumers.first().cheating()){ //when cheating, just set status to 1 @@ -218,7 +219,7 @@ public class PowerGraph{ return; } - lastFrameUpdated = Core.graphics.getFrameId(); + lastFrameUpdated = state.updateId; float powerNeeded = getPowerNeeded(); float powerProduced = getPowerProduced();