游戏跨服开发笔记

服务器管理入口


@Component
@SocketClass
public class ServerFacade {

    // 所有游戏服连接时,合服事件
    @ReceiverAnno
    public void onAllGameServerConnected(EventMergeServer  eventMergeServer) {
        serverGroupManager.mergeServerExitGroup(eventMergeServer.getGameServer(), eventMergeServer.getChildServerIds());
    }

    // 所有游戏服连接时,所有游戏服连接事件
    @ReceiverAnno
    public void onAllGameServerConnected() {
        serverGroupManager.forceRefresh();
    }

    // 注册
    @SocketMethod
    public void register(Wsession wsessoin, G2cGameServerRegister g2cGameServerRegister) {
        gameServerManager.register(wsession, g2cGameServerRegister);
    }

    // 游戏服心跳事件
    @SocketMethod
    public void register(GameSerever gameServer, G2cGameServerRegist g2cGameServerHeartbeat) {
         gameServerManager.register(wsession,  g2cGameServerHeartbeat);
    }
    // 战斗服心跳事件
    @SocketMethod
    public void transferHeartbeat(Wsession wsession, T2cTeansferHeartbeat) {
        transferServerManger.heartbeat(wsession, heartbeat);
    }
    // 接受分组参数
    @SocketMethod
    public void receiveGroupParam(GameServer gameServer, G2cGroupParamUpload paramUpload) {
        serverGroupManager.uploadGroupParam(gameServer, paramUpload);
    }
    // 重新分组
    @SocketMethod
    public void reServerGroup(GameServer gameServer, G2cCommandReServerGroup commandReServerGroup) {
        serverGroupManager.commandReGroup();
    }
    // 接受分组
    @SocketMethod
    public void assignServerGroup(GameServer gameServer, G2cCommandAssignServerGroupResp cCommandAssignServerGroupResp) {
        serverGroupManager.commandAssignGroup(gameServer.getEntity(), cCommandAssignServerGroupResp.getGroupType());
    }

}



// 游戏服 - 跨服处理相关入口
//  注意:Wsession不是玩家会话,是游戏服与中心服的会话

```java
@Componet
@SocketClass
public class GameServerFacade {

    // 开服连接
    @ReceiverAnno
    public void onGameServerOpen(ServerOpenEvent event) {
        if(serverManager.getOpenDay().isOpen()) {
            centerManager.connect();
        }
    }

    // 开服了,注册到中心服
    @ReceiverAnno
    public void onServerDayBegin(ServerDayBeginEvent event) {
        centerManager.connect();
    }

    // 连接成功了,尝试注册,如果这时候服务器未达成开服条件,不注册
    @ReceiverAnno
    public void onConnerctCenter(EventCenterC) {
        if(ServerType.isGameServer()) {
            gameServerService.tryRegisterGameServer2Center();
        }
    }

    // 玩家断开连接,检测是否需要退出跨服
    @ReceiverAnno
    public void onPlayerLogout(LogoutEvent event) {
        Player player = event.getPlayer();
        gameServerService.onPlayerOffine(player);
    }

    // 收到门票,开始前往跨服
    @SocketMethod
    public void receiverTicketFormCenter() {
        gamServerService.beginEnter(ticketResp.getPlayerId(), ticketResp.getTransferIp(),
        ticketResp.getTransferPort(), ticketResp.getTicket());
    }

    // 确认退出跨服
    @SocketMethod
    public void confirmExitTransfer(Player player, T2gTransferExitConfirmResp resp) {
        gameServerService.exitTransferConfirm(player, resp);
    }

    // 中心服发送给玩家的通知信息
    @SocketMethod
    public void sendMessage(Wsession wsession, C2GMessagePacket, c2GMessagePacket) {
        gameServerService.sendMessagePacket(c2GMessagePacket);
    }

    // 确认进入跨服
    @SocketMethod
    public void enterTransfer(Player player, T2gTransferEnteredResp resp) {
        player.submitEvent(TeansgerEnteredEvent.valueOf(resp.getTransferType()));
    }

    // 收到中心返回的所有跨服地图门票信息
    // 中心服会在启动完成初始化之后,主动推送到游戏服
    @SocketMethod
    public void receiveTransportTicketFromCenter(Wsesion wsession, C2gTransportTicketsResp c2TransportTicketsResp) {

    }

    // 收到中心服返回的分组信息
    @SocketMethod
    public void receiveServerGroupInfoFromCneter(Wsession wsession, C2gServerGroupType2ServerIds c2gServerGroupType2ServerIds) {

    }

    // 收到战斗服回传的玩家事件
    @SocketMethod(coustomPacketId = CrossPacketId.GLOBAL_EVENT_RESP)
    @IRunInNioThread
    public void transferPlayerEvent(Wsession wsession, IEvent eventPacket) {
        // 异步提交事件
        String simpleName = eventPacket.getClass().getSimpleName();
        EventBusManager.getInstance().sumbmit(eventPacket, "globalEvent_"+SimpleName, simpleName.hashCode());
    }

    // 跨服发奖
    @SocketMethod
    public void gainReward(Player player, T2gGainRewardResp t2gGainRewardResp) {
        palyer.gainReward(t2gGainRewardResp.getReward(), t2gGainRewardResp.getModuleInfo());
    }

    // 战斗服 -> 游戏服 跨服事务
    @SocketMethod
    public T2gTransactionResp transaction(Player player, T2gTransactioReq) {

    }

    // 分组销毁
    @SocketMethod
    public void receiveServerGroupDestroy(Wsession wsession, C2gTicketsDetroyPacket c2gTicketsDestroyPacket) {
        centerManger.onServerGroupDestroy(c2gTicketDestroyPacket.getServerGroupType());
    }
}



服务器管理逻辑处理

跨服类型

    @Desc("跨服3V3")
    transfer_t3v3,
    @Desc("跨服地图")
    transfer_land,
    @Desc("跨服灭神塔")
    transfer_godKillTower,
    @Desc("跨服灭神塔全服")
    transfer_godKillTowerServer,
    @Desc("跨服沙巴克")
    shabake,

    // ...

服务器分组类型

@Desc("服务器哦分组类型")
public enum ServerGroupType {
    @Desc("3v3")
    t3v3(false, false),
    @Desc("第四大陆")
    land4(true, true),
    @Desc("第五大陆")
    land5(true, true),
    @Desc("公会秘境争霸")
    secretArea(false,false)


    // 该战区是否预分配战斗服
    private boolean groupOnServerOpen;

    // 预分配战斗服之后,是否通知战斗服,检测该战区下面的地图是否需要预创建地图, ps:只判断是否发送检测通知
    // 具体某一张地图是否预创建,是检测地图配置MapType枚举里面的initTrandferMap字段
    private boolean initMapAfterGroupTransfer;
}

跨服门票

@ProtobufClass
public class Ticket {
    @Protobuf(description = "跨服类型")
    private TransferType type;
    @Protobuf(description = "门票id,同一")
    private long ticketId;
     @Protobuf(description = "分组类型(战区)")
    private ServerGroupType groupType;
     @Protobuf(description = "组号")
    private int groupIndex;

    // 其他额外信息

    @Protobuf(description = "阵营类型")
    private CampType campType;
    @Protobuf(description = "前往的跨服地图ID")
    private int mapId;
    @Protobuf(description = "x")
    private int x;
    @Protobuf(description = "y")
    private int y;

    // 创建一张门票 ...

}


中心服通用业务处理

@Component
public class CenterManager {
    // ...

    // 创建一个唯一的门票id
    public long createTicketId() {
        return idGen.getAndIncrement();
    }
1
    // 发送门票到游戏服
    public void sendTicket(TransferType transferType, long playerId, int gameServerId, TransferServer transferServer, Ticket ticket) {
        GameServer gameServer = gameServerManager.getSreverIfRegister(gameServerId);
        if(gameServer == null) {
            return;
        }
        C2gTransferTicketResp resp = C2gTransferTicketResp.valueOf(
            transferServer.getIp(),
            transferServer.getPort(),
            playerId,
            ticket
        );
        // 通知游戏服
        gameServer.send(resp);
    }
}

游戏线程模型学习

任务调度器

public class dentityEventExectorGroup {

    private static fanal String EXECUTOR_NAME_PREFIX = "Identity-dispatcher";

    static private EventExecutor[] childrens;
    static private Profile threadProfile = new Profile();
    static private Profile taskProfile = new Prfile;

    // 初始化, nThreads 线程数量
    synchronized public static void init() {
        if(childrens == null) {
            childrens = new EventExecutor[nThreads];
            ThreadFactory threadFactory = new DefaultThreadFactory(EXECUTOR_NAME_PREFIX);
            for(int i=0; i< nThreaeds; i++) {
                EventExecutor eventExecutors = new EventExecutor(i+1, null, threadDactory, true);
                childrebs[i] = eventEventors;
            }
        }
    }

    public static void shutdown() {
        for(EventExcutor c : childrens) {
            c.shutdownGracefully();
        }
    }

    private static EventExcutor takeExecutor(int dispatcherHashCode) {
        return childrens[Math.abs(dispatcherHashCode % childrens.length)];
    }

    // 添加同步任务
    public static Future<?> addTask(AbstractDispatcherHashCodeRunnable dispatcherHashCodRunnable) {
        checkName(dispatcherHashCodRunnable.name());
        EventExecutor eventExecutor = takeExecutor(dispatcherHashCodRunnable.getDispatcherHashCode());
        dispathcherHashCodeRunnable.submit(eventExecutor.getIndex(),false);
        return eventExecutor.addTask(dispatcherHashCodeRunnable);
    }

    // 添加同步任务
    public static Future<?> addTask(int dispatcherCode,String name, Runnable runnable) {
        checkName(name);
        return addTask(new AbstractDispatcherHashCodeRunnable() {
            @Override
            public String name() {
                return name;
            }
            @Ovveride
            public int getDispatcherHashCode() {
                return dispatcherCode();
            }
            @Override
            public void doRun() {
                runnable.ru();
            }
        });
    }

    // 添加延迟任务
    public static ScheduleFuture<?> addScheduleTask(AbstractDispatcherHashCodeRunnable dispatcherHashCodRunnable, long delay, TimeUtil unit) {
         checkName(dispatcherHashCodRunnable.name());
        EventExecutor eventExecutor = takeExecutor(dispatcherHashCodRunnable.getDispatcherHashCode());
        dispathcherHashCodeRunnable.submit(eventExecutor.getIndex(),false);
        return eventExecutor.addScheduleTask(dispatcherHashCodeRunnable);
    }

    public static ScheduledFuture<?> addScheduleTask(int dispatcherHashCode, String name,  String name, long delay, TimeUtil unit, Runnable runnable) {
        checkName(name);
        return addScheduleTask(new AbstractDispatcherHashCodeRunnable() {
            @Override
            public int getDispatcherHashCode() {
                return dispatcherHashCode;
            }
            @Override
            public String name() {
                return name;
            }
            @Override
            public void doRun() {
                runnable.run();
            }
        }, delay, unit);
    }

    // 添加定时器任务
    // 该任务按照周期执行
    public static addScheduleAtFixedRate(AbstractDispatcherHashCodeRunnable dispatcherHashCodeRunnable,
    long initialDeley, long period, TimeUnit unit) {
        checkName(dispatcherHashCodeRunnable.name());
        EventExecutor eventExecutor = taskExecutor(dispatcherHashCodeRunnable.getDisatcherHashCode());
        // 延迟任务等待耗时统计无意义
        dispatcherHashCodeRunnable.submit(eventExecutor.getIndex(), true);
        return eventExecutor.addScheduleAtFixedRate(dispatcherHashCodeRunnable, initialDelay, period, unit);
    }

    // 添加定时器任务
    // 该任务按照周期执行
    public static ScheduledFuture<?> addScheduleAtFixedRate(int dispatcherHashCode, String name,  String name, long initialDelay, long  period, TimeUtil unit, Runnable runnable ) {
         return addScheduleAtFixedRate(new AbstractDispatcherHashCodeRunnable() {
            @Override
            public int getDispatcherHashCode() {
                return dispatcherHashCode;
            }
            @Override
            public String name() {
                return name;
            }
            @Override
            public void doRun() {
                runnable.run();
            }
        }, initialDelay,period, unit);
    }

    // 添加定时器任务
    // 该任务按延迟时间执行
    public static addScheduleAtFixedRate(AbstractDispatcherHashCodeRunnable dispatcherHashCodeRunnable,
    long initialDeley, long period, TimeUnit unit) {
        checkName(dispatcherHashCodeRunnable.name());
        EventExecutor eventExecutor = taskExecutor(dispatcherHashCodeRunnable.getDisatcherHashCode());
        // 延迟任务等待耗时统计无意义
        dispatcherHashCodeRunnable.submit(eventExecutor.getIndex(), true);
        return eventExecutor.addScheduleAtFixedDelay(dispatcherHashCodeRunnable, initialDelay, period, unit);
    }

    // 添加定时器任务
    // 该任务按照延迟时间执行
    public static ScheduledFuture<?> addScheduleAtFixedRate(int dispatcherHashCode, String name,  String name, long initialDelay, long  period, TimeUtil unit, Runnable runnable ) {
         return addScheduleAtFixedDelay(new AbstractDispatcherHashCodeRunnable() {
            @Override
            public int getDispatcherHashCode() {
                return dispatcherHashCode;
            }
            @Override
            public String name() {
                return name;
            }
            @Override
            public void doRun() {
                runnable.run();
            }
        }, initialDelay,period, unit);
    }

    // 获取线程池线程数量

    public static getThreadNum() {
        return childrens.length;
    }

    // 等待线程池前面提交的任务全部执行完成
    public static void blockWaitRunOver() {
        int threadNum = getThreadNum();
        AtomicInteger ready = new AtomicInteger(threadNum);
        // 注意,这里是要原子变量的任务塞满任务队列
        for(int i =0; i< threadNum; i++>) {
            addTask(i, "checkReady", ready::decrementAndGet);
        }
        // 直到所有加入任务队列的原子减一任务执行完毕,ready的值变为0,才会结束while循环,结束函数结束阻塞
        while(ready.get() > 0) {
                try{
                    TimeUnit.MillLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
        }
    }

    private static void checkName(String name) {
        if(StringUtils.isEmpty(name)) {
            throw new NullPointerException("name is null!");
        }
    }
} 

事件执行器

class EventExecutor extends SingleTreadEventExecutor {

    private int index;

    protected EventExecutor(int index, EventExecutorGroup parent, ThreadFacotry threadFactory, boolean addTaskWakeUp{   super(parent, threadFactory, addTaskWakesUp);
        this.index = index;
    }

    @Override
    protected void run() {
        do {
            Runnable task = takeTask();
            if(task != null) {
                task.run();
                updateLastExecutionTime();
            }
        } while (!confirmShutdown());
    }

    public ScheduleFuture<?> addScheduleTask(AbstractDispatcherHashCodeRunnable task, long delay, TimeUnit unit) {
        return schedule(task, deley, unit);
    }

    // ... 接下来一堆定时器方法


    public Future<?> addTask() {
        return submit(task);
    }

    public int getIndex() {
        return index;
    }
}

任务分发标记器

public abstract class AbstractDispatcherHashCodeRunnable implements Runnable {

    private Consumer<AbstractDispatcherHashCodeRunnable> runBefore;
    private Consumer<AbstractDispatcherHashCodeRunnable> runAfter;

    // 是否为定时任务
    private boolean scheduleTask;
    private int threadIndex;
    private long submitTime;

    // 执行的任务
    abstract public void doRun();

    // 用于分发的编号
    public abstract int getDispatcherHashCode();

    // 任务类型,同一种类型任务添加一种任务即可
    public abstract String name();

    // 该任务纳秒时间
    protected long timeoutNanoTime() {
        return TimeUnit.MILLISECONDS.toNanos(1);
    }

    public void submit(int threadIndex, boolean scheduleTask) {
        this.threadIndex = threadIndex;
        this.scheduleTask = scheduleTask;
        this.submitTime = System.nanoTime();
        IdentityEventExecutorGroup.recordSubmit(name());
    }

    @Override
    final public void run() {
        long start = System.nanoTime();
        try {
            if(runBefore != null) {
                runBefore.accept(this);
            }
            doRun();
            if(runAfter != null) {
                runAfter.accept(this);
            }
        } catch(Throwable e) {
            IdentityEventExecutorGroup.recordException(name());
        } finally {
            long now = System.nanoTime();
            long useNanoTime = now - start;
            long waitNanoTime = start - submitTime;
            IdentityEventExcutorGroup.recordExc(name(), threadIndex, scheduleTask, useNanoTime, waitNanoTime);
            this.submitTime = now;
        }
    }

    // 任务前后函数的get/set方法 ...

}

io线程池

Io线程池是一种特殊的存在,用来处理有IO操作的业务。

public class IoThreadPool {

    // 统计任务名前缀
    private static final String IO_WORK_PREFIX = "IO-WORK-";
    // 线程名前缀
    private static final String THREAD_NAME_PREFIX = "io-thread-pool";
    // 线程池线程保持存货x分钟
    private staitc final int KEEP_ALIVE_MIN = 1;
    // 队伍中允许的最大容量
    private static final QUEUE_CAPACITY = 5000;

    private static final LinkedBlockingDeque<Runnable> workQueue = new LinkedBlockDeque<>();

    private static ExecutorService executorService;

    // 队列满再提交失败次数
    private static AtomicInteger submitFailCounter = new AtomicInteger(0);
    // 队列长读最大值
    private static AtomicInteger queueMax = new AtomicInteger(0);
    // 拒绝服务数量峰值
    private static int maxSubmitFail;
    private staitc Profile profile = new Profile();

    // 初始化id线程池
    public static void init(int nThread) {
        executorService = new ThreadPoolExecutor(
            nThread / 2 ,
            nThread,
            KEEP_ALIVE_MIN,
            TimeUnit.MINUTES,
            workQueue,
            new DefaultThreadFactory(THREAD_NAME_PREFIX),
            (r, executor) -> {
                // 业务被拒绝,队列已满,线程已全部被占用,直接在当前线程执行业务代码
                r.run();
                // 提交失败次数+1
                submitFailCounter.incrementAndGet();
            }
        );
    }

    // 添加io业务
    public static void execute(String name, Runnable runnable) {
        if(StringUtils.isEmpty(name)) {
            throw new NullPointerException("name is null");
        }
        executorService.submit(IoWork.valueOf(name, runnable));
        queueMax.accumulateAndGet(workQueue.size(), Math::max);
    }

    // 关闭io线程池,最多等待15s
    public statac void shutdown() throws InterruptedException {
        executorService.awaitTermination(15, TimeUnit.SECONDS);
    }

    // 获取提交失败峰值
    public static int getMaxSubmitFail() {
        return mexSubmitFail;
    }
    // 获取队列最大长度
    public static int getQueueMax() {
        return queueMax.get();
    }

    // 性能统计信息
    private static class IoWork implements Runnable {
        String name;
        Runnable runnable;
        ProInfo proInfo;
        long submitTime;

        privte static IoWork valueOf(String name, Runnable runnable) {
            IoWork vo = new IoWork();
            vo.name = IO_WORK_PREFIX + name;
            vo.runnable = runnable;
            vo.proInfo = profile.getOrCreateProInfo(name);
            vo.proInfo.recordSubmit();
            vo.subimitTime = System.nanoTime();
            return vo;
        }

        @Override
        public voisd run() {
            long start = System.nanoTime();
            try {
                runnable.run();
            } catch(Exception e) {
                proInfo.recordException();
            }
            proInfo.recordExc(System.nanoTime() - start, 0);
        }
    }
}

游戏生物的抽象

世界唯一对象 AbstractMirObject

构造器

public AbstractMirObject(IMirObjectSoulFill mirObjectSoulFill) {
    setobjectId(mirObjectSoulFill.createId());
}

public interface IMirObjectSoulFill {
    // 生成单位唯一id
    long createId();
}

主要属性

  • 对象唯一id protected long objectId
  • 对象id的包装类型 protected long objId

可见物 AbstractVisibleObject extends AbsreactMirObject

主要属性

  • 配置id int objectkey
  • 观察者列表 KnownList knownList
  • 视野位面 int planes (决定视野可见,等于0表示可被所有位面可见)
  • 位置 WorldPosition position
  • 控制器 AbstractVisibleObjectController controller
  • 可见对象的目标 AbstractVisibleObject target
  • 刷新资源 SpawnResource spawnResource
  • 出生的X轴 bornX
  • 出生的Y轴 bornY
  • 朝向 DirectionEnum bornHeading

  • 该对象的心跳业务 Map<ObjectTickType, objectTick><?> tickMap 主要用于防止取消心跳,统一取消

  • 是否为出生 boolean birth 用于客户端表现出生特效

  • 过滤器 FilterController filterController
  • 人物状态 MergeSubmit<CreatureUpdateType> creatureUpdateTask

构造器

public AbstractVisibleObject(IVisibleObjectSoulFill visibleObjectSoulFill) {
    super(visibleObjectSoulFill);

    this.filterController = new FilterController();
    this.objectKet = visibleObjectSoulFill.getObjKey();
    this.controller = visibleObjectSoulFill.createController(this);
    this.controller.setOwner(this);
    this.position = visibleObjectSoulFill.createPosition();
    this.knownList = visibleObjectSoulFill.createKnownList(this);
    creatureUpdateTask = new MergeSubmit<>(CreatureUpdateType.class);
}


启动一个针对该对象的心跳,使用者要注意定时业务的回收,避免内存泄漏

public <T extends AbstractVisibleObject> ObjectTick<T> createTick(ObjectTickType tickType, long initialDelay, long initialDelay, 
long initialDelay, long period, TimeUnit timeUnit, Tick<T> tick) {

    ObjectTick exits = tickMap.remove(tickType);
    if(exist != null) {
        exist.tryCancle();
        LOGGER.error("exist tick task"+ tickType);
    }
    ObjectTick<T>  objectTick = ObjectTick.valueOf(tickType,(T) this, initialDelay, period, timeUnit);
    tickMap.put(tickType,objectTick)
    return objectTick;
}

添加会合并的延迟任务,同一种CreatureUpdateType类型的任务.在延迟时间内提交过多次也只会执行一次
PS:这里的延迟任务是不保证关服时一定能够执行完成,如果必须是在关服前执行的任务,请在玩家等处或者停服接口处执行
public void addMergeDelayTask(CreatureUpdateType type, int dispatcherCode, String name, int delay, TimeUnit timeUnit, Runnable runnable)


AbstractCreature 生物体,有血有肉的 AbstractCreature extends AbstractVisibleObject

  • 攻击模式
  • 移动控制器
  • 使用中技能
  • 生物血量状态 CreatureLifeStats<?> lifeStats
  • 生物属性管理器 CreatureGameStats gameStats
  • 效果管理器
  • 事件管理器 ObserveController observeController
  • 技能管理器

  • 实际扣血控制器

  • 所有的召唤物,每种类型的召唤物在场上可能有多个,像五条狗这种 Map<SummonType, List<Summon>>

  • 有时效的标记 AgingMark<CreatureAgingMark> agingMark

构造器

public AbstractCreature(ICreatureSoulFill creatureSoulFill) {
    super(creatureSoulFill);

    this.lifeStats  = creatureSoulFill.createLifeStats(this);
    this.lifeStats.init();
    this.moveController = new MoveController(this);
    this.observeController = creatureSoulFill.createObserveController(this);
    this.gameStats = creatureSoulFill.createCreatureGameStats(this);
    this.effectController = creatureSoulFill.createEffectController(this);
    this.skillController = creatureSoulFill.createSkillController(this);
    this.agingMark = new AgingMark();
}

方法

  • 获取单位等级

NPC Npc extends AbstractCreature

  • 路由 RouteRoad
  • AI AbstractAi ai
  • 生成时间 long createTime
  • 抽象仇恨列表 AbstractAggroList aggroList
  • 攻击时间 Map<Long, Long> ontrAttackTime

  • 攻击控制器 NpcAttackController attackController

  • 当前选中的目标 AbstractCreature curTarget

  • 当前目标加入时间 long curTargetJoinTime
    *
    构造器

public NPC(INpcSoulFill npcSoulFill) {
    super(npcSoulFill);

    this.aggroList = npcSoulFill.createAggroList(this);
    this.followPolicy = npcSoulFill.createFollowPolicy(this);
    this.attackControler = ncSoulFill.createAttackController(this);

    createTime = System.currentTimeMillis();
}
  • ObjectResource getObjectResource() 获取NVPC的对象配置
  • ObjectFIghtAttrResource getObjectFightAttrResource()

怪物实体 Monster extends NPC

  • boolean needRefreshAggroList() 判断某些事情发生(仇恨列表中的玩家死亡)后,该怪物是否需要立刻刷新仇恨列表

重写的方法

  • Object createShowInfo(FightPlayer witness) 创建客户端显示用的信息体
  • boolean canGo(int targetX, int targetY) 是否可以到达

NPC攻击控制器

  • 攻击任务 `Future<?> attackTask