스타봇/스타봇 강의 및 개발

스타크래프트 봇 테란편 (최종편)

오잎 클로버 2021. 12. 30. 21:00
728x90

이제 스타크래프트 봇은 해당 편을 마지막으로 막을 내립니다....

이때까지 스타크래프트 봇을 봐주신 많은 분들께 감사를 표하며 스타크래프트 봇 글을 끝내도록 하겠습니다.

 

하지만 이번편은 끝내고 끝내야지...

라는 마음으로 글을 작성해봅니다.

 

프로토스 편에 작성하였던 ExampleUtil를 사용하였으며

빌드는 기존에는 초반러쉬로 게임을 끝내버렸다면

마지막편이고 테란편인 만큼 

고급 유닛인 벌처를 사용하도록 하였습니다.

 

먼저 개발한 클래스는 다음과 같습니다.

Main, ExampleBot, ExampleUtil, Vulture, Worker

Main은 늘 동일하니 건너뛰도록 하겠습니다.

public class Main {

    public static void main(String[] args) {
        try {
            new ExampleBot().run();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

Worker 클래스를 개발하여 일꾼에 관해서 보다 효율적으로 사용하기 위해 개발하였습니다.

enum을 사용하였습니다.

enum을 사용한 이유는 일꾼에게 직업을 부여하여 특정 직업에게만 특정 작업을 하기 위해서입니다.

public enum Jobs {
    Mineral,
    Gas,
    Idle,
    Scout
}

직업은 단순하게 미네랄, 가스, 기본, 정찰 입니다.

public static final HashMap<Jobs, ArrayList<Unit>> workerJobs = new HashMap<>();
public static Unit scoutWorker;

직업 HashMap과 정찰 일꾼을 변수로 따로 지정을 하고

public Unit unit;

public Worker(Unit unit) {
    this.unit = unit;

    getAllWorkerJobs(Jobs.Idle).add(unit);
}

생성자로 일꾼을 등록합니다.

public ArrayList<Unit> getAllWorkerJobs(Jobs job) {
    return workerJobs.computeIfAbsent(job, k -> new ArrayList<>());
}

해당 직업이 HashMap에 없을 경우 ArrayList를 생성해서 넣어줍니다.

그리곤 .add 를 하여 일꾼을 추가합니다.

 

public void workerDead() {
    getAllWorkerJobs(getWorkerJob()).remove(unit);
}

일꾼이 죽었을 가능성을 대비하여 삭제도 해줍니다.

public Jobs getWorkerJob() {
    for (Jobs jobs : Jobs.values()) {
        if (getAllWorkerJobs(jobs).contains(unit)) {
            return jobs;
        }
    }

    return Jobs.Idle;
}

일꾼이 포함되어있는 직업을 반환해주고

만일 예상치 못할 경우 기본 상태를 반환해줍니다.

public void changeJob(Jobs jobs) {
    if (getAllWorkerJobs(jobs).contains(unit)) {
        return;
    }

    if (jobs == Jobs.Scout) {
        if (scoutWorker != null) {
            return;
        }
        scoutWorker = unit;
        for (Jobs job : Jobs.values()) {
            getAllWorkerJobs(job).remove(unit);
        }

        getAllWorkerJobs(jobs).add(unit);
        unit.stop();
        return;
    }

    if (getWorkerJob() == Jobs.Scout && jobs != Jobs.Idle) {
        return;
    }

    getAllWorkerJobs(getWorkerJob()).remove(unit);
    getAllWorkerJobs(jobs).add(unit);
    unit.stop();
}

대망의 직업 변경입니다.

직업 변경을 통해 직업을 변경하여 즉시 행동을 수행하기 위해 unit.stop을 하여주었습니다.

 

그러고 나면 이제 일을 하는 영역을 개발해주어야하므로

public void processWork(Unit unit) {
    if (unit == null) {
        return;
    }

    if (!unit.exists()) {
        return;
    }

    if (!unit.isIdle()) {
        return;
    }

    switch (getWorkerJob()) {
        case Idle:
            break;
        case Mineral:
            gatherMinerals(unit);
            break;
        case Gas:
            gatherGas(unit);
            break;
        case Scout:
            processScout();
            break;
    }

}

프레임마다 작동시킬 부분이기에 노는 상태일 때만 작동하도록 하였습니다.

(stop을 하면 노는 상태로 인식합니다.)

 

public void gatherMinerals(Unit unit) {
    Unit closestMineral = ExampleUtil.getClosestUnitTo(unit, ExampleBot.BroodWar.getMinerals());

    if (closestMineral != null) {
        unit.gather(closestMineral);
    }
}

미네랄 채취 메소드는 하도 많이 봐서 굳이 설명드리지 않아도 될 것 같습니다.

public void gatherGas(Unit unit) {

    if (!ExampleUtil.hasRefinery()) {
        return;
    }

    Unit closestGas = ExampleUtil.getClosestUnitTo(unit, ExampleBot.BroodWar.getStaticGeysers());

    if (closestGas != null) {
        unit.gather(closestGas);
    }
}

가스 채취소가 있는 지 여부를 묻고 없다면 return해버립니다.

만일 있다면 가스를 채취하도록 합니다.

public void processScout() {

    if (scoutWorker == null) {
        return;
    }

    List<TilePosition> startLocations = ExampleBot.BroodWar.getStartLocations();
    for (TilePosition tp : startLocations) {
        if (ExampleBot.BroodWar.isExplored(tp)) {
            continue;
        }

        Position pos = tp.toPosition();

        scoutWorker.move(pos);
    }

    if (ExampleBot.enemyBase != Position.Unknown && scoutWorker != null) {
        System.out.println("상대 위치: " + ExampleBot.enemyBase);
        scoutWorker.move(ExampleBot.BroodWar.self().getStartLocation().toPosition());
        changeJob(Jobs.Idle);
        scoutWorker = null;
    }
}

위 부분은 정찰 부분인데

정찰 일꾼으로 정찰을 하여 적군 위치를 파악했을 경우 콘솔에 메시지를 보내는 형식입니다.

 

public void setScoutWorker() {
    changeJob(Jobs.Scout);
}

그리고 정찰 유닛으로 지정하는 메소드 역시 존재합니다.

public void update() {
    if (getWorkerJob() == Jobs.Idle) {
        // Idle 이라면 미네랄 채취하도록 한다.
        changeJob(Jobs.Mineral);
    }

    if (ExampleUtil.hasRefinery()) {
        if (getAllWorkerJobs(Jobs.Gas).size() < 3) {
            changeJob(Jobs.Gas);
        }
    }

    processWork(unit);
}

기본 상태라면 무조건 Mineral 이라는 직업을 주어 일을 하도록 합니다.

 

 

이제 벌처 관련 클래스인

Vulture를 개발해봅시다.

 

일단 Vulture 클래스를 만든 이후는 벌처의 꽃은 마인이자 벌처 컨트롤 (이하 벌컨) 입니다.

그렇기때문에 따로 벌컨을 하도록 하는 클래스를 개발하였습니다.

 

public static final Boolean DEBUG = true;

public Unit vulture;
public boolean checkBase;
public boolean requireKite;

public ArrayList<Unit> rangedUnitTargets;

public Vulture(Unit vulture) {
    this.vulture = vulture;
    this.checkBase = false;
    this.requireKite = true;
    rangedUnitTargets = new ArrayList<>();
}

벌처를 지정, 벌컨 여부, checkBase 같은 경우에는 원래 더 복잡한 컨트롤을 위해 만들긴하였지만

그렇기에는 너무 복잡해질 것 같아 사용하지는 않았습니다.

(물론 이를 제외하더라도 굉장히 복잡합니다.)

 

먼저

public WeaponType getWeapon(Unit attacker, Unit target) {
    return target.isFlying() ? attacker.getType().airWeapon() : attacker.getType().groundWeapon();
}

메소드를 정의합니다.

타겟이 날아다니는 적인지 아니면 지상에 있는 적인지 판단하기 위함과 동시에 거리 조절때문에 작성하였습니다.

public double calculateLTD(Unit attacker, Unit target) {
    WeaponType weapon = getWeapon(attacker, target);

    if (weapon == WeaponType.None) {
        return 0;
    }

    return (double) (weapon.damageAmount()) / weapon.damageCooldown();
}

LTD는 유한 사거리(?)라고 말해야하나요?

어찌되었든 간에 사거리 비교 및 피해에 따른 DPS를 계산하기 위해 작성하였습니다.

 

그리고 난 후 공격 대상 우선순위를 지정하기 위해 일종의 노가다를 뛰었습니다.

public int getAttackPriority(Unit unit, Unit target) {

    UnitType unitType = unit.getType();
    UnitType targetType = target.getType();

    if (unitType == UnitType.Zerg_Scourge) {
        if (targetType == UnitType.Protoss_Carrier) {
            return 100;
        }
        if (targetType == UnitType.Protoss_Corsair) {
            return 90;
        }
    }

    boolean isTreat = unitType.isFlyer() ? targetType.airWeapon() != WeaponType.None : targetType.groundWeapon() != WeaponType.None;

    if (targetType.isWorker()) {
        isTreat = false;
    }

    if (targetType == UnitType.Zerg_Larva || targetType == UnitType.Zerg_Egg) {
        return 0;
    }

    if (unit.isFlying() && targetType == UnitType.Protoss_Carrier) {
        return 101;
    }

    Position ourBasePosition = ExampleBot.BroodWar.self().getStartLocation().toPosition();
    if (targetType.isWorker()
            && (target.isConstructing() || target.isRepairing())
            && target.getDistance(ourBasePosition) < 32 * 35) {
        return 100;
    }

    if (targetType.isBuilding()
            && (target.isCompleted() || target.isBeingConstructed())
            && target.getDistance(ourBasePosition) < 32 * 35) {
        return 90;
    }

    if (targetType == UnitType.Terran_Bunker || isTreat) {
        return 11;
    }
    else if (targetType.isWorker()) {
        if (unitType == UnitType.Terran_Vulture) {
            return 11;
        }

        return 11;
    }
    else if (targetType == UnitType.Zerg_Spawning_Pool
            || targetType == UnitType.Protoss_Pylon) {
        return 5;
    }
    else if (targetType.gasPrice() > 0) {
        return 4;
    }
    else if (targetType.mineralPrice() > 0) {
        return 3;
    }
    else {
        return 1;
    }
}

대충 int를 반환하는 것으로 보아 비교를 통해 우선순위가 정해지는 것 같다는 것은 아마 아실 겁니다.

 

이를 기반으로 타겟을 지정합니다.

public Unit getTarget(ArrayList<Unit> targets) {

    int highPriority = 0;
    double closestDist = Double.MAX_VALUE;
    Unit closestTarget = null;

    for (Unit t : targets) {
        double dist = vulture.getDistance(t);
        int priority = getAttackPriority(vulture, t);

        if (closestTarget == null
                || priority > highPriority
                || (priority == highPriority && dist < closestDist)) {
            closestDist = dist;
            highPriority = priority;
            closestTarget = t;
        }
    }

    return closestTarget;
}

그리고 난 후

update를 작성합니다.

public void update() {
        // 모든 적군 유닛들 중 시야에 보이는 적군만 rangedUnitTargets 에 넣고
        // 그 중에서도 우선순위를 정해서 최종 타겟을 정한 후, 카이팅을 한다.
        rangedUnitTargets.clear();

        for (Unit unit : ExampleBot.BroodWar.enemy().getUnits()) {
            if (unit == null || !unit.exists()) {
                continue;
            }

            if (!unit.isCompleted()) {
                continue;
            }

            if (!unit.isVisible(ExampleBot.BroodWar.self())) {
                continue;
            }

            rangedUnitTargets.add(unit);
        }

        Unit target = getTarget(rangedUnitTargets);

        if (target != null) {


            if (DEBUG) {
                ExampleBot.BroodWar.drawLineMap(vulture.getPosition(), vulture.getTargetPosition(), Color.Purple);
            }

//            requireKite = needKiteState(rangedUnitTargets);

            if (requireKite) {
                action(target);
            } else {
                ExampleUtil.attackUnit(vulture, target);
            }
        }
        else {
            if (vulture.getDistance(ExampleBot.enemyBase) > 32 * 3) {
                ExampleUtil.attackMove(vulture, ExampleBot.enemyBase);
            }
        }
    }

위 메소드들을 응용해서 update를 개발합니다.

update에 action 메소드를 개발해줍니다.

 

그러기 전에 벌처 컨트롤을 통해 이동할 경로를 일부 지정한 후 하면 보다 효율적이게 벌처 컨트롤이 가능하기에

대충 개발하였습니다.

public Position getKiteVector(Unit unit, Unit target) {
    int targetX = target.getPosition().x;
    int targetY = target.getPosition().y;
    int unitX = unit.getPosition().x;
    int unitY = unit.getPosition().y;

    Position fleeVector = new Position(targetX - unitX, targetY - unitY);
    double fleeAngle = Math.atan2(fleeVector.y, fleeVector.x);
    fleeVector = new Position((int)(64 * Math.cos(fleeAngle)), (int)(64 * Math.sin(fleeAngle)));
    return fleeVector;
}

(위 방식은 아쉽게도 벽과 같이 이동 못하는 곳으로 이동할 수도 있습니다.)

public void action(Unit target) {
    if (vulture == null || target == null) {
        return;
    }

    int coolDown = vulture.getType().groundWeapon().damageCooldown();
    int latency = ExampleBot.BroodWar.getLatency().ordinal();
    double speed = vulture.getType().topSpeed();
    double range = vulture.getType().groundWeapon().maxRange();
    double distanceToTarget = vulture.getDistance(target);
    double distanceToFiringRange = Math.max(distanceToTarget - range, 0.0);
    double timeToEnterFiringRange = distanceToFiringRange / speed;
    int framesToAttack = (int) (timeToEnterFiringRange) + 2 * latency;

    int currentCoolDown = vulture.isStartingAttack() ? coolDown : vulture.getGroundWeaponCooldown();

    Position fleeVector = getKiteVector(target, vulture);
    Position moveToPosition = new Position(
            vulture.getPosition().x + fleeVector.x,
            vulture.getPosition().y + fleeVector.y
    );

    if (currentCoolDown <= framesToAttack) {
        vulture.attack(target);
    }
    else {
        if (moveToPosition.isValid(ExampleBot.BroodWar)) {
            vulture.rightClick(moveToPosition);
        }
    }
}

이를 통해 벌처 컨트롤이 개발되었습니다.

public void actionExecute() {
    update();
}

외부에서도 인자값 없이 사용하기 위해 actionExecute 메소드를 정의했습니다.

ExampleBot에 대한 코드는 아래를 참고해주세요.

https://github.com/iqpizza6349/StarCraft_BroodWar_BasicBot/blob/master/src/main/java/com/tistory/workshop6349/examplebotT/ExampleBot.java

 

아래는 시연 영상입니다.

Vulture.rep
0.12MB

생각했던 것보다 굉장히 별로이지만 이것으로 만족하였습니다.

 

이상으로 마무리입니다.

(방학에는 스타크래프트2로 다시 돌아오겠습니다.)