Multiple unit stance support

This commit is contained in:
Anuken
2025-07-01 23:23:45 -04:00
parent b6195cc31e
commit 88fc46fed2
8 changed files with 141 additions and 42 deletions

View File

@@ -8,17 +8,26 @@ import mindustry.type.*;
public class ItemUnitStance extends UnitStance{
private static ObjectMap<Item, ItemUnitStance> itemToStance = new ObjectMap<>();
private static Seq<ItemUnitStance> all = new Seq<>();
public final Item item;
public ItemUnitStance(Item item){
super("item-" + item.name, "item-" + item.name, null);
this.item = item;
incompatibleStances.add(UnitStance.mineAuto).addAll(UnitStance.mineAuto.incompatibleStances);
itemToStance.put(item, this);
all.add(this);
}
public static @Nullable ItemUnitStance getByItem(Item item){
return itemToStance.get(item);
return item == null ? null : itemToStance.get(item);
}
public static Seq<ItemUnitStance> all(){
return all;
}
@Override

View File

@@ -3,6 +3,7 @@ package mindustry.ai;
import arc.*;
import arc.input.*;
import arc.scene.style.*;
import arc.struct.*;
import arc.util.*;
import mindustry.*;
import mindustry.ctype.*;
@@ -17,11 +18,33 @@ public class UnitStance extends MappableContent{
public String icon;
/** Key to press for this stance. */
public @Nullable KeyBind keybind;
/** Stances that are mutually exclusive to this stance. This is used for convenience, for writing only! */
public Seq<UnitStance> incompatibleStances = new Seq<>();
/** Incompatible stances as a bitset for easier operations. This is where incompatibility is actually stored. */
public Bits incompatibleBits = new Bits(1);
/** If true, this stance can be toggled on or off. */
public boolean toggle = true;
public UnitStance(String name, String icon, KeyBind keybind){
public UnitStance(String name, String icon, KeyBind keybind, boolean toggle){
super(name);
this.icon = icon;
this.keybind = keybind;
this.toggle = toggle;
}
public UnitStance(String name, String icon, KeyBind keybind){
this(name, icon, keybind, true);
}
@Override
public void init(){
super.init();
for(var stance : incompatibleStances){
if(stance == this) continue;
incompatibleBits.set(stance.id);
stance.incompatibleBits.set(id);
}
}
public String localized(){
@@ -47,13 +70,15 @@ public class UnitStance extends MappableContent{
}
public static void loadAll(){
stop = new UnitStance("stop", "cancel", Binding.cancelOrders);
shoot = new UnitStance("shoot", "commandAttack", Binding.unitStanceShoot);
holdFire = new UnitStance("holdfire", "none", Binding.unitStanceHoldFire);
stop = new UnitStance("stop", "cancel", Binding.cancelOrders, false);
shoot = new UnitStance("shoot", "commandAttack", Binding.unitStanceShoot, false);
holdFire = new UnitStance("holdfire", "none", Binding.unitStanceHoldFire, false);
pursueTarget = new UnitStance("pursuetarget", "right", Binding.unitStancePursueTarget);
patrol = new UnitStance("patrol", "refresh", Binding.unitStancePatrol);
ram = new UnitStance("ram", "rightOpen", Binding.unitStanceRam);
mineAuto = new UnitStance("mineauto", "settings", null);
mineAuto = new UnitStance("mineauto", "settings", null, false);
shoot.incompatibleStances.add(holdFire);
//Only vanilla items are supported for now
for(Item item : Vars.content.items()){

View File

@@ -40,15 +40,20 @@ public class CommandAI extends AIController{
protected float payloadPickupCooldown;
protected int transferState = transferStateNone;
/** Stance, usually related to firing mode. */
public UnitStance stance = UnitStance.shoot;
/** Current command this unit is following. */
public UnitCommand command;
/** Stance, usually related to firing mode. Each bit is a stance ID. */
public Bits stances = new Bits(content.unitStances().size);
/** Current controller instance based on command. */
protected @Nullable AIController commandController;
/** Last command type assigned. Used for detecting command changes. */
protected @Nullable UnitCommand lastCommand;
{
//TODO: is this necessary when 'hold fire' can be a toggle?
setStance(UnitStance.shoot);
}
public UnitCommand currentCommand(){
return command == null ? UnitCommand.moveCommand : command;
}
@@ -63,6 +68,35 @@ public class CommandAI extends AIController{
}
}
public boolean hasStance(@Nullable UnitStance stance){
return stance != null && stances.get(stance.id);
}
public void setStance(UnitStance stance, boolean enabled){
if(enabled){
setStance(stance);
}else{
disableStance(stance);
}
}
public void setStance(UnitStance stance){
stances.andNot(stance.incompatibleBits);
stances.set(stance.id);
stanceChanged();
}
public void disableStance(UnitStance stance){
stances.clear(stance.id);
stanceChanged();
}
public void stanceChanged(){
if(commandController != null && !(commandController instanceof CommandAI)){
commandController.stanceChanged();
}
}
@Override
public void init(){
if(command == null){
@@ -82,21 +116,18 @@ public class CommandAI extends AIController{
@Override
public void updateUnit(){
//this should not be possible
if(stance == UnitStance.stop) stance = UnitStance.shoot;
//fix incorrect stance when mining
if(command == UnitCommand.mineCommand && stance != UnitStance.mineAuto && !(stance instanceof ItemUnitStance)){
stance = UnitStance.mineAuto;
if(command == UnitCommand.mineCommand && !hasStance(UnitStance.mineAuto) && !ItemUnitStance.all().contains(this::hasStance)){
setStance(UnitStance.mineAuto);
}
//pursue the target if relevant
if(stance == UnitStance.pursueTarget && target != null && attackTarget == null && targetPos == null){
if(hasStance(UnitStance.pursueTarget) && !hasStance(UnitStance.patrol) && target != null && attackTarget == null && targetPos == null){
commandTarget(target, false);
}
//pursue the target for patrol, keeping the current position
if(stance == UnitStance.patrol && target != null && attackTarget == null){
if(hasStance(UnitStance.patrol) && hasStance(UnitStance.pursueTarget) && target != null && attackTarget == null){
//commanding a target overwrites targetPos, so add it to the queue
if(targetPos != null){
commandQueue.add(targetPos.cpy());
@@ -207,6 +238,8 @@ public class CommandAI extends AIController{
finishPath();
}
boolean ramming = hasStance(UnitStance.ram);
if(attackTarget != null){
if(targetPos == null){
targetPos = new Vec2();
@@ -214,7 +247,7 @@ public class CommandAI extends AIController{
}
targetPos.set(attackTarget);
if(unit.isGrounded() && attackTarget instanceof Building build && build.tile.solid() && unit.type.pathCostId != ControlPathfinder.costIdLegs && stance != UnitStance.ram){
if(unit.isGrounded() && attackTarget instanceof Building build && build.tile.solid() && unit.type.pathCostId != ControlPathfinder.costIdLegs && !ramming){
Tile best = build.findClosestEdge(unit, Tile::solid);
if(best != null){
targetPos.set(best);
@@ -225,7 +258,7 @@ public class CommandAI extends AIController{
boolean alwaysArrive = false;
float engageRange = unit.type.range - 10f;
boolean withinAttackRange = attackTarget != null && unit.within(attackTarget, engageRange) && stance != UnitStance.ram;
boolean withinAttackRange = attackTarget != null && unit.within(attackTarget, engageRange) && !ramming;
if(targetPos != null){
boolean move = true, isFinalPoint = commandQueue.size == 0;
@@ -239,16 +272,17 @@ public class CommandAI extends AIController{
Building targetBuild = world.buildWorld(targetPos.x, targetPos.y);
//TODO: should the unit stop when it finds a target?
if(
(stance == UnitStance.patrol && target != null && unit.within(target, unit.type.range - 2f) && !unit.type.circleTarget) ||
(hasStance(UnitStance.patrol) && !hasStance(UnitStance.pursueTarget) && target != null && unit.within(target, unit.type.range - 2f) && !unit.type.circleTarget) ||
(command == UnitCommand.enterPayloadCommand && unit.within(targetPos, 4f) || (targetBuild != null && unit.within(targetBuild, targetBuild.block.size * tilesize/2f * 0.9f))) ||
(command == UnitCommand.loopPayloadCommand && unit.within(targetPos, 10f))
){
move = false;
}
if(unit.isGrounded() && stance != UnitStance.ram){
if(unit.isGrounded() && !ramming){
//TODO: blocking enable or disable?
if(timer.get(timerTarget3, avoidInterval)){
Vec2 dstPos = Tmp.v1.trns(unit.rotation, unit.hitSize/2f);
@@ -284,7 +318,7 @@ public class CommandAI extends AIController{
noFound[0] = false;
vecOut.set(vecMovePos);
}else{
move = controlPath.getPathPosition(unit, vecMovePos, targetPos, vecOut, noFound) && (!blockingUnit || timeSpentBlocked > maxBlockTime);
move &= controlPath.getPathPosition(unit, vecMovePos, targetPos, vecOut, noFound) && (!blockingUnit || timeSpentBlocked > maxBlockTime);
//TODO: what to do when there's a target and it can't be reached?
/*
@@ -320,7 +354,7 @@ public class CommandAI extends AIController{
moveTo(vecOut,
withinAttackRange ? engageRange :
unit.isGrounded() ? 0f :
attackTarget != null && stance != UnitStance.ram ? engageRange : 0f,
attackTarget != null && !ramming ? engageRange : 0f,
unit.isFlying() ? 40f : 100f, false, null, isFinalPoint || alwaysArrive);
}
}
@@ -412,7 +446,7 @@ public class CommandAI extends AIController{
commandPosition(position);
}
if(prev != null && (stance == UnitStance.patrol || command == UnitCommand.loopPayloadCommand)){
if(prev != null && (hasStance(UnitStance.patrol) || command == UnitCommand.loopPayloadCommand)){
commandQueue.add(prev.cpy());
}
@@ -454,7 +488,7 @@ public class CommandAI extends AIController{
@Override
public boolean shouldFire(){
return stance != UnitStance.holdFire;
return !hasStance(UnitStance.holdFire);
}
@Override

View File

@@ -13,6 +13,14 @@ public class MinerAI extends AIController{
public Item targetItem;
public Tile ore;
@Override
public void stanceChanged(){
if(targetItem != null && unit.controller() instanceof CommandAI ai && !ai.hasStance(UnitStance.mineAuto) && !ai.hasStance(ItemUnitStance.getByItem(targetItem))){
mining = false;
targetItem = null;
}
}
@Override
public void updateMovement(){
Building core = unit.closestCore();
@@ -23,13 +31,15 @@ public class MinerAI extends AIController{
unit.mineTile(null);
}
Item autoItem = unit.controller() instanceof CommandAI ai && ai.stance instanceof ItemUnitStance stance ? stance.item : null;
CommandAI ai = unit.controller() instanceof CommandAI a ? a : null;
if(mining){
if(autoItem != null){
targetItem = autoItem;
}else if(timer.get(timerTarget2, 60 * 4) || targetItem == null){
targetItem = unit.type.mineItems.min(i -> indexer.hasOre(i) && unit.canMine(i), i -> core.items.get(i));
if(timer.get(timerTarget2, 60 * 4) || targetItem == null){
if(ai != null && !ai.hasStance(UnitStance.mineAuto)){
targetItem = content.items().min(i -> indexer.hasOre(i) && unit.canMine(i) && ai.hasStance(ItemUnitStance.getByItem(i)), i -> core.items.get(i));
}else{
targetItem = unit.type.mineItems.min(i -> indexer.hasOre(i) && unit.canMine(i), i -> core.items.get(i));
}
}
//core full of the target item, do nothing

View File

@@ -51,6 +51,9 @@ public class AIController implements UnitController{
updateMovement();
}
/** Called when the parent CommandAI changes its stance. */
public void stanceChanged(){}
/**
* @return whether controller state should not be reset after reading.
* Do not override unless you know exactly what you are doing.

View File

@@ -372,15 +372,18 @@ public abstract class InputHandler implements InputProcessor, GestureListener{
//make sure its current stance is valid with its current command
stancesOut.clear();
unit.type.getUnitStances(unit, stancesOut);
if(stancesOut.size > 0 && !stancesOut.contains(ai.stance)){
ai.stance = stancesOut.first();
for(var stance : content.unitStances()){
//disable stances that the unit does not support anymore (TODO: this is slow!)
if(ai.hasStance(stance) && !stancesOut.contains(stance)){
ai.disableStance(stance);
}
}
}
}
}
@Remote(called = Loc.server, targets = Loc.both, forward = true)
public static void setUnitStance(Player player, int[] unitIds, UnitStance stance){
public static void setUnitStance(Player player, int[] unitIds, UnitStance stance, boolean enable){
if(player == null || unitIds == null || stance == null) return;
if(net.server() && !netServer.admins.allowAction(player, ActionType.commandUnits, event -> {
@@ -395,7 +398,8 @@ public abstract class InputHandler implements InputProcessor, GestureListener{
if(stance == UnitStance.stop){ //not a real stance, just cancels orders
ai.clearCommands();
}else if(unit.type.allowStance(unit, stance)){
ai.stance = stance;
//if toggle is not allowed, the stance will always be set to true when pressed
ai.setStance(stance, !stance.toggle || enable);
}
unit.lastCommanded = player.coloredName();
}

View File

@@ -523,7 +523,7 @@ public class TypeIO{
write.b(3);
write.i(logic.controller.pos());
}else if(control instanceof CommandAI ai){
write.b(8);
write.b(9);
write.bool(ai.attackTarget != null);
write.bool(ai.targetPos != null);
@@ -559,7 +559,16 @@ public class TypeIO{
}
}
writeStance(write, ai.stance);
int count = content.unitStances().count(ai::hasStance);
write.b(count);
for(var stance : content.unitStances()){
if(ai.hasStance(stance)){
writeStance(write, stance);
}
}
}else if(control instanceof AssemblerAI){ //hate
write.b(5);
}else{
@@ -591,8 +600,8 @@ public class TypeIO{
out.controller = world.build(pos);
return out;
}
//type 4 is the old CommandAI with no commandIndex, type 6 is the new one with the index as a single byte, type 7 is the one with the command queue, 8 adds a stance
}else if(type == 4 || type == 6 || type == 7 || type == 8){
//type 4 is the old CommandAI with no commandIndex, type 6 is the new one with the index as a single byte, type 7 is the one with the command queue, 8 adds a stance, 9 adds multiple stances
}else if(type == 4 || type == 6 || type == 7 || type == 8 || type == 9){
CommandAI ai = prev instanceof CommandAI pai ? pai : new CommandAI();
boolean hasAttack = read.bool(), hasPos = read.bool();
@@ -616,14 +625,14 @@ public class TypeIO{
ai.attackTarget = null;
}
if(type == 6 || type == 7 || type == 8){
if(type == 6 || type == 7 || type == 8 || type == 9){
byte id = read.b();
ai.command = id < 0 ? null : content.unitCommand(id);
if(ai.command == null) ai.command = UnitCommand.moveCommand;
}
//command queue only in type 7/8
if(type == 7 || type == 8){
if(type == 7 || type == 8 || type == 9){
ai.commandQueue.clear();
int length = read.ub();
for(int i = 0; i < length; i++){
@@ -646,7 +655,12 @@ public class TypeIO{
}
if(type == 8){
ai.stance = readStance(read);
ai.setStance(readStance(read));
}else if(type == 9){
int stances = read.ub();
for(int i = 0; i < stances; i++){
ai.setStance(readStance(read));
}
}
return ai;

View File

@@ -581,7 +581,7 @@ public class PlacementFragment{
for(var stance : stances){
coms.button(stance.getIcon(), Styles.clearNoneTogglei, () -> {
Call.setUnitStance(player, units.mapInt(un -> un.id, un -> un.type.allowStance(un, stance)).toArray(), stance);
Call.setUnitStance(player, units.mapInt(un -> un.id, un -> un.type.allowStance(un, stance)).toArray(), stance, !activeStances.get(stance.id));
}).checked(i -> activeStances.get(stance.id)).size(50f).tooltip(stance.localized(), true);
if(++scol % 6 == 0) coms.row();
@@ -604,7 +604,7 @@ public class PlacementFragment{
for(var unit : control.input.selectedUnits){
if(unit.controller() instanceof CommandAI cmd){
activeCommands.set(cmd.command.id);
activeStances.set(cmd.stance.id);
activeStances.set(cmd.stances);
}
stancesOut.clear();
@@ -631,7 +631,7 @@ public class PlacementFragment{
for(UnitStance stance : stances){
//first stance must always be the stop stance
if(stance.keybind != null && Core.input.keyTap(stance.keybind)){
Call.setUnitStance(player, control.input.selectedUnits.mapInt(un -> un.id, un -> un.type.allowStance(un, stance)).toArray(), stance);
Call.setUnitStance(player, control.input.selectedUnits.mapInt(un -> un.id, un -> un.type.allowStance(un, stance)).toArray(), stance, !activeStances.get(stance.id));
}
}