From 55edd53f840efa689ec488f379541d66f4eb4d75 Mon Sep 17 00:00:00 2001 From: Anuken Date: Sat, 30 Jul 2022 21:01:07 -0400 Subject: [PATCH] WIP command order system --- core/assets/bundles/bundle.properties | 5 + core/src/mindustry/ai/UnitCommand.java | 52 +++++++ core/src/mindustry/ai/types/BuilderAI.java | 28 +++- core/src/mindustry/ai/types/CommandAI.java | 147 ++++++++++++------ core/src/mindustry/content/UnitTypes.java | 8 +- .../mindustry/entities/comp/BuilderComp.java | 2 + core/src/mindustry/input/InputHandler.java | 32 +++- core/src/mindustry/io/TypeIO.java | 20 ++- core/src/mindustry/type/UnitType.java | 32 ++++ .../ui/fragments/PlacementFragment.java | 61 +++++++- gradle.properties | 2 +- 11 files changed, 327 insertions(+), 62 deletions(-) create mode 100644 core/src/mindustry/ai/UnitCommand.java diff --git a/core/assets/bundles/bundle.properties b/core/assets/bundles/bundle.properties index ef68679c83..1ae8346c40 100644 --- a/core/assets/bundles/bundle.properties +++ b/core/assets/bundles/bundle.properties @@ -311,6 +311,11 @@ open = Open customize = Customize Rules cancel = Cancel command = Command +command.mine = Mine +command.repair = Repair +command.rebuild = Rebuild +command.assist = Assist Player +command.move = Move openlink = Open Link copylink = Copy Link back = Back diff --git a/core/src/mindustry/ai/UnitCommand.java b/core/src/mindustry/ai/UnitCommand.java new file mode 100644 index 0000000000..e8ce93d3a9 --- /dev/null +++ b/core/src/mindustry/ai/UnitCommand.java @@ -0,0 +1,52 @@ +package mindustry.ai; + +import arc.*; +import arc.func.*; +import arc.struct.*; +import mindustry.ai.types.*; +import mindustry.entities.units.*; +import mindustry.gen.*; + +/** Defines a pattern of behavior that an RTS-controlled unit should follow. Shows up in the command UI. */ +public class UnitCommand{ + /** List of all commands by ID. */ + public static final Seq all = new Seq<>(); + + public static final UnitCommand + + //TODO they do not use the command "interface" or designation at all + moveCommand = new UnitCommand("move", "right", u -> null), + repairCommand = new UnitCommand("repair", "modeSurvival", u -> new RepairAI()), + rebuildCommand = new UnitCommand("rebuild", "hammer", u -> new BuilderAI()), + assistCommand = new UnitCommand("assist", "players", u -> { + var ai = new BuilderAI(); + ai.onlyAssist = true; + return ai; + }), + mineCommand = new UnitCommand("mine", "production", u -> new MinerAI()); + + /** Default set of specified commands. */ + public static final UnitCommand[] defaultCommands = {moveCommand}; + + /** Unique ID number. */ + public final int id; + /** Named used for tooltip/description. */ + public final String name; + /** Name of UI icon (from Icon class). */ + public final String icon; + /** Controller that this unit will use when this command is used. Return null for "default" behavior. */ + public final Func controller; + + public UnitCommand(String name, String icon, Func controller){ + this.name = name; + this.icon = icon; + this.controller = controller; + + id = all.size; + all.add(this); + } + + public String localized(){ + return Core.bundle.get("command." + name); + } +} diff --git a/core/src/mindustry/ai/types/BuilderAI.java b/core/src/mindustry/ai/types/BuilderAI.java index 8e3af71ce6..dd88f09354 100644 --- a/core/src/mindustry/ai/types/BuilderAI.java +++ b/core/src/mindustry/ai/types/BuilderAI.java @@ -14,12 +14,14 @@ import static mindustry.Vars.*; public class BuilderAI extends AIController{ public static float buildRadius = 1500, retreatDst = 110f, retreatDelay = Time.toSeconds * 2f; + public @Nullable Unit assistFollowing; public @Nullable Unit following; public @Nullable Teamc enemy; public @Nullable BlockPlan lastPlan; public float fleeRange = 370f; public boolean alwaysFlee; + public boolean onlyAssist; boolean found = false; float retreatTimer; @@ -41,6 +43,10 @@ public class BuilderAI extends AIController{ unit.updateBuilding = true; + if(assistFollowing != null && assistFollowing.activelyBuilding()){ + following = assistFollowing; + } + if(following != null){ retreatTimer = 0f; //try to follow and mimic someone @@ -108,6 +114,10 @@ public class BuilderAI extends AIController{ } }else{ + if(assistFollowing != null){ + moveTo(assistFollowing, assistFollowing.type.hitSize * 1.5f + 60f); + } + //follow someone and help them build if(timer.get(timerTarget2, 60f)){ found = false; @@ -130,13 +140,29 @@ public class BuilderAI extends AIController{ } } }); + + if(onlyAssist){ + float minDst = Float.MAX_VALUE; + Player closest = null; + for(var player : Groups.player){ + if(player.unit().canBuild() && !player.dead()){ + float dst = player.dst2(unit); + if(dst < minDst){ + closest = player; + minDst = dst; + } + } + } + + assistFollowing = closest == null ? null : closest.unit(); + } } //TODO this is bad, rebuild time should not depend on AI here float rebuildTime = (unit.team.rules().rtsAi ? 12f : 2f) * 60f; //find new plan - if(!unit.team.data().plans.isEmpty() && following == null && timer.get(timerTarget3, rebuildTime)){ + if(!onlyAssist && !unit.team.data().plans.isEmpty() && following == null && timer.get(timerTarget3, rebuildTime)){ Queue blocks = unit.team.data().plans; BlockPlan block = blocks.first(); diff --git a/core/src/mindustry/ai/types/CommandAI.java b/core/src/mindustry/ai/types/CommandAI.java index 8d50b296a5..7e58a6c56d 100644 --- a/core/src/mindustry/ai/types/CommandAI.java +++ b/core/src/mindustry/ai/types/CommandAI.java @@ -12,20 +12,61 @@ import mindustry.gen.*; import mindustry.world.*; public class CommandAI extends AIController{ - private static final float localInterval = 40f; - private static final Vec2 vecOut = new Vec2(), flockVec = new Vec2(), separation = new Vec2(), cohesion = new Vec2(), massCenter = new Vec2(); + 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(); public @Nullable Vec2 targetPos; public @Nullable Teamc attackTarget; - private boolean stopAtTarget; - private Vec2 lastTargetPos; - private int pathId = -1; - private Seq local = new Seq<>(false); - private boolean flocked; + protected boolean stopAtTarget; + protected Vec2 lastTargetPos; + protected int pathId = -1; + protected Seq local = new Seq<>(false); + protected boolean flocked; + + /** Current command this unit is following. */ + public @Nullable UnitCommand command; + /** Current controller instance based on command. */ + protected @Nullable AIController commandController; + /** Last command type assigned. Used for detecting command changes. */ + protected @Nullable UnitCommand lastCommand; + + public @Nullable UnitCommand currentCommand(){ + return command; + } + + /** Attempts to assign a command to this unit. If not supported by the unit type, does nothing. */ + public void command(UnitCommand command){ + if(Structs.contains(unit.type.commands, command)){ + //clear old state. + unit.mineTile = null; + unit.clearBuilding(); + this.command = command; + } + } @Override public void updateUnit(){ + + //assign defaults + if(command == null && unit.type.commands.length > 0){ + command = unit.type.defaultCommand == null ? unit.type.commands[0] : unit.type.defaultCommand; + } + + //update command controller based on index. + var curCommand = currentCommand(); + if(lastCommand != curCommand){ + lastCommand = curCommand; + commandController = (curCommand == null ? null : curCommand.controller.get(unit)); + } + + //use the command controller if it is provided, and bail out. + if(commandController != null){ + if(commandController.unit() != unit) commandController.unit(unit); + commandController.updateUnit(); + return; + } + updateVisuals(); updateTargeting(); @@ -104,6 +145,7 @@ public class CommandAI extends AIController{ } if(attackTarget == null){ + //TODO overshoot. if(unit.within(targetPos, Math.max(5f, unit.hitSize / 2f))){ targetPos = null; }else if(local.size > 1){ @@ -134,10 +176,55 @@ public class CommandAI extends AIController{ } } + @Override + public boolean keepState(){ + return true; + } + + @Override + public Teamc findTarget(float x, float y, float range, boolean air, boolean ground){ + return attackTarget == null || !attackTarget.within(x, y, range + 3f + (attackTarget instanceof Sized s ? s.hitSize()/2f : 0f)) ? super.findTarget(x, y, range, air, ground) : attackTarget; + } + + @Override + public boolean retarget(){ + //retarget faster when there is an explicit target + return attackTarget != null ? timer.get(timerTarget, 10) : timer.get(timerTarget, 20); + } + + public boolean hasCommand(){ + return targetPos != null; + } + + public void setupLastPos(){ + lastTargetPos = targetPos; + } + + public void commandPosition(Vec2 pos){ + targetPos = pos; + lastTargetPos = pos; + attackTarget = null; + pathId = Vars.controlPath.nextTargetId(); + } + + public void commandTarget(Teamc moveTo){ + commandTarget(moveTo, false); + } + + public void commandTarget(Teamc moveTo, boolean stopAtTarget){ + attackTarget = moveTo; + this.stopAtTarget = stopAtTarget; + pathId = Vars.controlPath.nextTargetId(); + } + + /* + + //TODO ひどい + (does not work) + public static float cohesionScl = 0.3f; public static float cohesionRad = 3f, separationRad = 1.1f, separationScl = 1f, flockMult = 0.5f; - //TODO ひどい Vec2 calculateFlock(){ if(local.isEmpty()) return flockVec.setZero(); @@ -177,47 +264,5 @@ public class CommandAI extends AIController{ } return flockVec; - } - - @Override - public boolean keepState(){ - return true; - } - - @Override - public Teamc findTarget(float x, float y, float range, boolean air, boolean ground){ - return attackTarget == null || !attackTarget.within(x, y, range + 3f + (attackTarget instanceof Sized s ? s.hitSize()/2f : 0f)) ? super.findTarget(x, y, range, air, ground) : attackTarget; - } - - @Override - public boolean retarget(){ - //retarget faster when there is an explicit target - return attackTarget != null ? timer.get(timerTarget, 10) : timer.get(timerTarget, 20); - } - - public boolean hasCommand(){ - return targetPos != null; - } - - public void setupLastPos(){ - lastTargetPos = targetPos; - - } - - public void commandPosition(Vec2 pos){ - targetPos = pos; - lastTargetPos = pos; - attackTarget = null; - pathId = Vars.controlPath.nextTargetId(); - } - - public void commandTarget(Teamc moveTo){ - commandTarget(moveTo, false); - } - - public void commandTarget(Teamc moveTo, boolean stopAtTarget){ - attackTarget = moveTo; - this.stopAtTarget = stopAtTarget; - pathId = Vars.controlPath.nextTargetId(); - } + }*/ } diff --git a/core/src/mindustry/content/UnitTypes.java b/core/src/mindustry/content/UnitTypes.java index 98f00ea233..58b96df7a3 100644 --- a/core/src/mindustry/content/UnitTypes.java +++ b/core/src/mindustry/content/UnitTypes.java @@ -6,6 +6,7 @@ import arc.math.*; import arc.math.geom.*; import arc.struct.*; import arc.util.*; +import mindustry.ai.*; import mindustry.ai.types.*; import mindustry.annotations.Annotations.*; import mindustry.entities.*; @@ -1247,8 +1248,11 @@ public class UnitTypes{ //region air support mono = new UnitType("mono"){{ + //there's no reason to command monos anywhere. it's just annoying. controller = u -> new MinerAI(); + defaultCommand = UnitCommand.mineCommand; + flying = true; drag = 0.06f; accel = 0.12f; @@ -1266,7 +1270,7 @@ public class UnitTypes{ }}; poly = new UnitType("poly"){{ - controller = u -> new BuilderAI(); + defaultCommand = UnitCommand.rebuildCommand; flying = true; drag = 0.05f; @@ -1320,7 +1324,7 @@ public class UnitTypes{ }}; mega = new UnitType("mega"){{ - controller = u -> new RepairAI(); + defaultCommand = UnitCommand.repairCommand; mineTier = 3; mineSpeed = 4f; diff --git a/core/src/mindustry/entities/comp/BuilderComp.java b/core/src/mindustry/entities/comp/BuilderComp.java index 378776585a..d583ecf196 100644 --- a/core/src/mindustry/entities/comp/BuilderComp.java +++ b/core/src/mindustry/entities/comp/BuilderComp.java @@ -82,6 +82,8 @@ abstract class BuilderComp implements Posc, Statusc, Teamc, Rotc{ boolean infinite = state.rules.infiniteResources || team().rules().infiniteResources; buildCounter += Time.delta; + if(Float.isNaN(buildCounter) || Float.isInfinite(buildCounter)) buildCounter = 0f; + buildCounter = Math.min(buildCounter, 10f); while(buildCounter >= 1){ buildCounter -= 1f; diff --git a/core/src/mindustry/input/InputHandler.java b/core/src/mindustry/input/InputHandler.java index d27975e0bc..51286ea638 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.*; +import mindustry.ai.*; import mindustry.ai.types.*; import mindustry.annotations.Annotations.*; import mindustry.content.*; @@ -228,6 +229,10 @@ public abstract class InputHandler implements InputProcessor, GestureListener{ for(int id : unitIds){ Unit unit = Groups.unit.getByID(id); if(unit != null && unit.team == player.team() && unit.controller() instanceof CommandAI ai){ + + //implicitly order it to move + ai.command(UnitCommand.moveCommand); + if(teamTarget != null && teamTarget.team() != player.team()){ ai.commandTarget(teamTarget); }else if(posTarget != null){ @@ -246,6 +251,28 @@ public abstract class InputHandler implements InputProcessor, GestureListener{ } } + @Remote(called = Loc.server, targets = Loc.both, forward = true) + public static void setUnitCommand(Player player, int[] unitIds, UnitCommand command){ + if(player == null || unitIds == null || command == null) return; + + if(net.server() && !netServer.admins.allowAction(player, ActionType.commandUnits, event -> { + event.unitIDs = unitIds; + })){ + throw new ValidateException(player, "Player cannot command units."); + } + + for(int id : unitIds){ + Unit unit = Groups.unit.getByID(id); + if(unit != null && unit.team == player.team() && unit.controller() instanceof CommandAI ai){ + ai.command(command); + //reset targeting + ai.targetPos = null; + ai.attackTarget = null; + unit.lastCommanded = player.coloredName(); + } + } + } + @Remote(called = Loc.server, targets = Loc.both, forward = true) public static void commandBuilding(Player player, Building build, Vec2 target){ if(player == null || build == null || build.team != player.team() || !build.block.commandable || target == null) return; @@ -814,7 +841,7 @@ public abstract class InputHandler implements InputProcessor, GestureListener{ for(Unit unit : selectedUnits){ CommandAI ai = unit.command(); //draw target line - if(ai.targetPos != null){ + if(ai.targetPos != null && ai.command == UnitCommand.moveCommand){ Position lineDest = ai.attackTarget != null ? ai.attackTarget : ai.targetPos; Drawf.limitLine(unit, lineDest, unit.hitSize / 2f, 3.5f); @@ -825,7 +852,8 @@ public abstract class InputHandler implements InputProcessor, GestureListener{ Drawf.square(unit.x, unit.y, unit.hitSize / 1.4f + 1f); - if(ai.attackTarget != null){ + //TODO when to draw, when to not? + if(ai.attackTarget != null && ai.command == UnitCommand.moveCommand){ Drawf.target(ai.attackTarget.getX(), ai.attackTarget.getY(), 6f, Pal.remove); } } diff --git a/core/src/mindustry/io/TypeIO.java b/core/src/mindustry/io/TypeIO.java index 13887ed269..638ccad12e 100644 --- a/core/src/mindustry/io/TypeIO.java +++ b/core/src/mindustry/io/TypeIO.java @@ -6,6 +6,7 @@ import arc.math.geom.*; import arc.struct.*; import arc.util.*; import arc.util.io.*; +import mindustry.ai.*; import mindustry.ai.types.*; import mindustry.annotations.Annotations.*; import mindustry.content.TechTree.*; @@ -283,6 +284,14 @@ public class TypeIO{ return Nulls.unit; } + public static void writeCommand(Writes write, UnitCommand command){ + write.b(command.id); + } + + public static UnitCommand readCommand(Reads read){ + return UnitCommand.all.get(read.ub()); + } + public static void writeEntity(Writes write, Entityc entity){ write.i(entity == null ? -1 : entity.id()); } @@ -441,7 +450,7 @@ public class TypeIO{ write.b(3); write.i(logic.controller.pos()); }else if(control instanceof CommandAI ai){ - write.b(4); + write.b(6); write.bool(ai.attackTarget != null); write.bool(ai.targetPos != null); @@ -457,6 +466,7 @@ public class TypeIO{ write.i(((Unit)ai.attackTarget).id); } } + write.b(ai.command == null ? -1 : ai.command.id); }else if(control instanceof AssemblerAI){ //hate write.b(5); }else{ @@ -488,7 +498,8 @@ public class TypeIO{ out.controller = world.build(pos); return out; } - }else if(type == 4){ + //type 4 is the old CommandAI with no commandIndex, type 6 is the new one with the index as a single byte. + }else if(type == 4 || type == 6){ CommandAI ai = prev instanceof CommandAI pai ? pai : new CommandAI(); boolean hasAttack = read.bool(), hasPos = read.bool(); @@ -511,6 +522,11 @@ public class TypeIO{ ai.attackTarget = null; } + if(type == 6){ + byte id = read.b(); + ai.command = id < 0 ? null : UnitCommand.all.get(id); + } + return ai; }else if(type == 5){ //augh diff --git a/core/src/mindustry/type/UnitType.java b/core/src/mindustry/type/UnitType.java index 7f357a8b74..e44386198b 100644 --- a/core/src/mindustry/type/UnitType.java +++ b/core/src/mindustry/type/UnitType.java @@ -277,6 +277,11 @@ public class UnitType extends UnlockableContent{ /** Flags to target based on priority. Null indicates that the closest target should be found. The closest enemy core is used as a fallback. */ public BlockFlag[] targetFlags = {null}; + /** Commands available to this unit through RTS controls. An empty array means commands will be assigned based on unit capabilities in init(). */ + public UnitCommand[] commands = {}; + /** Command to assign to this unit upon creation. Null indicates the first command in the array. */ + public @Nullable UnitCommand defaultCommand; + /** color for outline generated around sprites */ public Color outlineColor = Pal.darkerMetal; /** thickness for sprite outline */ @@ -772,6 +777,33 @@ public class UnitType extends UnlockableContent{ canAttack = weapons.contains(w -> !w.noAttack); + //assign default commands. + if(commands.length == 0){ + Seq cmds = new Seq<>(UnitCommand.class); + + //TODO ???? + //if(canAttack){ + cmds.add(UnitCommand.moveCommand); + //} + + //healing, mining and building is only supported for flying units; pathfinding to ambiguously reachable locations is hard. + if(flying){ + if(canHeal){ + cmds.add(UnitCommand.repairCommand); + } + + if(buildSpeed > 0){ + cmds.add(UnitCommand.rebuildCommand, UnitCommand.assistCommand); + } + + if(mineSpeed > 0){ + cmds.add(UnitCommand.mineCommand); + } + } + + commands = cmds.toArray(); + } + //dynamically create ammo capacity based on firing rate if(ammoCapacity < 0){ float shotsPerSecond = weapons.sumf(w -> w.useAmmo ? 60f / w.reload : 0f); diff --git a/core/src/mindustry/ui/fragments/PlacementFragment.java b/core/src/mindustry/ui/fragments/PlacementFragment.java index bf53d97cbd..82174185a1 100644 --- a/core/src/mindustry/ui/fragments/PlacementFragment.java +++ b/core/src/mindustry/ui/fragments/PlacementFragment.java @@ -11,6 +11,8 @@ import arc.scene.ui.*; import arc.scene.ui.layout.*; import arc.struct.*; import arc.util.*; +import mindustry.*; +import mindustry.ai.*; import mindustry.content.*; import mindustry.core.*; import mindustry.entities.*; @@ -422,6 +424,8 @@ public class PlacementFragment{ commandTable.table(u -> { u.left(); int[] curCount = {0}; + UnitCommand[] currentCommand = {null}; + var commands = new Seq(); Runnable rebuildCommand = () -> { u.clearChildren(); @@ -431,12 +435,17 @@ public class PlacementFragment{ for(var unit : units){ counts[unit.type.id] ++; } + commands.clear(); + boolean firstCommand = false; + Table unitlist = u.table().growX().left().get(); + unitlist.left(); + int col = 0; for(int i = 0; i < counts.length; i++){ if(counts[i] > 0){ var type = content.unit(i); - u.add(new ItemImage(type.uiIcon, counts[i])).tooltip(type.localizedName).pad(4).with(b -> { - ClickListener listener = new ClickListener(); + unitlist.add(new ItemImage(type.uiIcon, counts[i])).tooltip(type.localizedName).pad(4).with(b -> { + var listener = new ClickListener(); //left click -> select b.clicked(KeyCode.mouseLeft, () -> control.input.selectedUnits.removeAll(unit -> unit.type != type)); @@ -450,16 +459,62 @@ public class PlacementFragment{ }); if(++col % 7 == 0){ - u.row(); + unitlist.row(); + } + + if(!firstCommand){ + commands.add(type.commands); + firstCommand = true; + }else{ + //remove commands that this next unit type doesn't have + commands.removeAll(com -> !Structs.contains(type.commands, com)); } } } + + if(commands.size > 1){ + u.row(); + + u.table(coms -> { + for(var command : commands){ + coms.button(Icon.icons.get(command.icon, Icon.cancel), Styles.clearNoneTogglei, () -> { + IntSeq ids = new IntSeq(); + for(var unit : units){ + ids.add(unit.id); + } + + Call.setUnitCommand(Vars.player, ids.toArray(), command); + }).checked(i -> currentCommand[0] == command).size(50f).tooltip(command.localized()); + } + }).fillX().padTop(4f).left(); + } }else{ u.add("[no units]").color(Color.lightGray).growX().center().labelAlign(Align.center).pad(6); } }; u.update(() -> { + boolean hadCommand = false; + UnitCommand shareCommand = null; + + //find the command that all units have, or null if they do not share one + for(var unit : control.input.selectedUnits){ + if(unit.isCommandable()){ + var nextCommand = unit.command().currentCommand(); + + if(hadCommand){ + if(shareCommand != nextCommand){ + shareCommand = null; + } + }else{ + shareCommand = nextCommand; + hadCommand = true; + } + } + } + + currentCommand[0] = shareCommand; + int size = control.input.selectedUnits.size; if(curCount[0] != size){ curCount[0] = size; diff --git a/gradle.properties b/gradle.properties index 2cd0ac54a5..55dc4c130f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,4 +25,4 @@ org.gradle.caching=true #used for slow jitpack builds; TODO see if this actually works org.gradle.internal.http.socketTimeout=100000 org.gradle.internal.http.connectionTimeout=100000 -archash=7d543096d5 +archash=1477681512