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{ public class ItemUnitStance extends UnitStance{
private static ObjectMap<Item, ItemUnitStance> itemToStance = new ObjectMap<>(); private static ObjectMap<Item, ItemUnitStance> itemToStance = new ObjectMap<>();
private static Seq<ItemUnitStance> all = new Seq<>();
public final Item item; public final Item item;
public ItemUnitStance(Item item){ public ItemUnitStance(Item item){
super("item-" + item.name, "item-" + item.name, null); super("item-" + item.name, "item-" + item.name, null);
this.item = item; this.item = item;
incompatibleStances.add(UnitStance.mineAuto).addAll(UnitStance.mineAuto.incompatibleStances);
itemToStance.put(item, this); itemToStance.put(item, this);
all.add(this);
} }
public static @Nullable ItemUnitStance getByItem(Item item){ 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 @Override

View File

@@ -3,6 +3,7 @@ package mindustry.ai;
import arc.*; import arc.*;
import arc.input.*; import arc.input.*;
import arc.scene.style.*; import arc.scene.style.*;
import arc.struct.*;
import arc.util.*; import arc.util.*;
import mindustry.*; import mindustry.*;
import mindustry.ctype.*; import mindustry.ctype.*;
@@ -17,11 +18,33 @@ public class UnitStance extends MappableContent{
public String icon; public String icon;
/** Key to press for this stance. */ /** Key to press for this stance. */
public @Nullable KeyBind keybind; 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); super(name);
this.icon = icon; this.icon = icon;
this.keybind = keybind; 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(){ public String localized(){
@@ -47,13 +70,15 @@ public class UnitStance extends MappableContent{
} }
public static void loadAll(){ public static void loadAll(){
stop = new UnitStance("stop", "cancel", Binding.cancelOrders); stop = new UnitStance("stop", "cancel", Binding.cancelOrders, false);
shoot = new UnitStance("shoot", "commandAttack", Binding.unitStanceShoot); shoot = new UnitStance("shoot", "commandAttack", Binding.unitStanceShoot, false);
holdFire = new UnitStance("holdfire", "none", Binding.unitStanceHoldFire); holdFire = new UnitStance("holdfire", "none", Binding.unitStanceHoldFire, false);
pursueTarget = new UnitStance("pursuetarget", "right", Binding.unitStancePursueTarget); pursueTarget = new UnitStance("pursuetarget", "right", Binding.unitStancePursueTarget);
patrol = new UnitStance("patrol", "refresh", Binding.unitStancePatrol); patrol = new UnitStance("patrol", "refresh", Binding.unitStancePatrol);
ram = new UnitStance("ram", "rightOpen", Binding.unitStanceRam); 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 //Only vanilla items are supported for now
for(Item item : Vars.content.items()){ for(Item item : Vars.content.items()){

View File

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

View File

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

View File

@@ -51,6 +51,9 @@ public class AIController implements UnitController{
updateMovement(); updateMovement();
} }
/** Called when the parent CommandAI changes its stance. */
public void stanceChanged(){}
/** /**
* @return whether controller state should not be reset after reading. * @return whether controller state should not be reset after reading.
* Do not override unless you know exactly what you are doing. * 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 //make sure its current stance is valid with its current command
stancesOut.clear(); stancesOut.clear();
unit.type.getUnitStances(unit, stancesOut); unit.type.getUnitStances(unit, stancesOut);
if(stancesOut.size > 0 && !stancesOut.contains(ai.stance)){ for(var stance : content.unitStances()){
ai.stance = stancesOut.first(); //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) @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(player == null || unitIds == null || stance == null) return;
if(net.server() && !netServer.admins.allowAction(player, ActionType.commandUnits, event -> { 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 if(stance == UnitStance.stop){ //not a real stance, just cancels orders
ai.clearCommands(); ai.clearCommands();
}else if(unit.type.allowStance(unit, stance)){ }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(); unit.lastCommanded = player.coloredName();
} }

View File

@@ -523,7 +523,7 @@ public class TypeIO{
write.b(3); write.b(3);
write.i(logic.controller.pos()); write.i(logic.controller.pos());
}else if(control instanceof CommandAI ai){ }else if(control instanceof CommandAI ai){
write.b(8); write.b(9);
write.bool(ai.attackTarget != null); write.bool(ai.attackTarget != null);
write.bool(ai.targetPos != 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 }else if(control instanceof AssemblerAI){ //hate
write.b(5); write.b(5);
}else{ }else{
@@ -591,8 +600,8 @@ public class TypeIO{
out.controller = world.build(pos); out.controller = world.build(pos);
return out; 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 //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){ }else if(type == 4 || type == 6 || type == 7 || type == 8 || type == 9){
CommandAI ai = prev instanceof CommandAI pai ? pai : new CommandAI(); CommandAI ai = prev instanceof CommandAI pai ? pai : new CommandAI();
boolean hasAttack = read.bool(), hasPos = read.bool(); boolean hasAttack = read.bool(), hasPos = read.bool();
@@ -616,14 +625,14 @@ public class TypeIO{
ai.attackTarget = null; ai.attackTarget = null;
} }
if(type == 6 || type == 7 || type == 8){ if(type == 6 || type == 7 || type == 8 || type == 9){
byte id = read.b(); byte id = read.b();
ai.command = id < 0 ? null : content.unitCommand(id); ai.command = id < 0 ? null : content.unitCommand(id);
if(ai.command == null) ai.command = UnitCommand.moveCommand; if(ai.command == null) ai.command = UnitCommand.moveCommand;
} }
//command queue only in type 7/8 //command queue only in type 7/8
if(type == 7 || type == 8){ if(type == 7 || type == 8 || type == 9){
ai.commandQueue.clear(); ai.commandQueue.clear();
int length = read.ub(); int length = read.ub();
for(int i = 0; i < length; i++){ for(int i = 0; i < length; i++){
@@ -646,7 +655,12 @@ public class TypeIO{
} }
if(type == 8){ 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; return ai;

View File

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