package emu.grasscutter.game.world; import emu.grasscutter.Grasscutter; import emu.grasscutter.data.GameData; import emu.grasscutter.data.GameDepot; import emu.grasscutter.data.binout.SceneNpcBornEntry; import emu.grasscutter.data.binout.routes.Route; import emu.grasscutter.data.excels.ItemData; import emu.grasscutter.data.excels.SceneData; import emu.grasscutter.data.excels.codex.CodexAnimalData; import emu.grasscutter.data.excels.monster.MonsterData; import emu.grasscutter.data.excels.world.WorldLevelData; import emu.grasscutter.data.server.Grid; import emu.grasscutter.game.avatar.Avatar; import emu.grasscutter.game.dungeons.DungeonManager; import emu.grasscutter.game.dungeons.DungeonSettleListener; import emu.grasscutter.game.dungeons.challenge.WorldChallenge; import emu.grasscutter.game.dungeons.enums.DungeonPassConditionType; import emu.grasscutter.game.entity.*; import emu.grasscutter.game.entity.gadget.GadgetWorktop; import emu.grasscutter.game.inventory.GameItem; import emu.grasscutter.game.managers.blossom.BlossomManager; import emu.grasscutter.game.player.Player; import emu.grasscutter.game.player.TeamInfo; import emu.grasscutter.game.props.*; import emu.grasscutter.game.quest.QuestGroupSuite; import emu.grasscutter.game.world.data.TeleportProperties; import emu.grasscutter.net.packet.BasePacket; import emu.grasscutter.net.proto.AttackResultOuterClass.AttackResult; import emu.grasscutter.net.proto.EnterTypeOuterClass; import emu.grasscutter.net.proto.SelectWorktopOptionReqOuterClass; import emu.grasscutter.net.proto.VisionTypeOuterClass.VisionType; import emu.grasscutter.scripts.SceneIndexManager; import emu.grasscutter.scripts.SceneScriptManager; import emu.grasscutter.scripts.constants.EventType; import emu.grasscutter.scripts.data.SceneBlock; import emu.grasscutter.scripts.data.SceneGroup; import emu.grasscutter.scripts.data.ScriptArgs; import emu.grasscutter.server.event.player.PlayerTeleportEvent; import emu.grasscutter.server.packet.send.*; import emu.grasscutter.utils.KahnsSort; import emu.grasscutter.utils.Position; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import lombok.Getter; import lombok.Setter; import lombok.val; import javax.annotation.Nullable; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; public final class Scene { @Getter private final World world; @Getter private final SceneData sceneData; @Getter private final List players; @Getter private final Map entities; @Getter private final Set spawnedEntities; @Getter private final Set deadSpawnedEntities; @Getter private final Set loadedBlocks; @Getter private final Set loadedGroups; @Getter private final BlossomManager blossomManager; private final HashSet unlockedForces; private final List afterLoadedCallbacks = new ArrayList<>(); private final long startWorldTime; @Getter @Setter DungeonManager dungeonManager; @Getter Int2ObjectMap sceneRoutes; private Set loadedGridBlocks; @Getter @Setter private boolean dontDestroyWhenEmpty; @Getter private final SceneScriptManager scriptManager; @Getter @Setter private WorldChallenge challenge; @Getter private List dungeonSettleListeners; @Getter @Setter private int prevScene; // Id of the previous scene @Getter @Setter private int prevScenePoint; @Getter @Setter private int killedMonsterCount; private Set npcBornEntrySet; @Getter private boolean finishedLoading = false; @Getter private int tickCount = 0; @Getter private boolean isPaused = false; public Scene(World world, SceneData sceneData) { this.world = world; this.sceneData = sceneData; this.players = new CopyOnWriteArrayList<>(); this.entities = new ConcurrentHashMap<>(); this.prevScene = 3; this.sceneRoutes = GameData.getSceneRoutes(getId()); this.startWorldTime = world.getWorldTime(); this.spawnedEntities = ConcurrentHashMap.newKeySet(); this.deadSpawnedEntities = ConcurrentHashMap.newKeySet(); this.loadedBlocks = ConcurrentHashMap.newKeySet(); this.loadedGroups = ConcurrentHashMap.newKeySet(); this.loadedGridBlocks = new HashSet<>(); this.npcBornEntrySet = ConcurrentHashMap.newKeySet(); this.scriptManager = new SceneScriptManager(this); this.blossomManager = new BlossomManager(this); this.unlockedForces = new HashSet<>(); } public int getId() { return sceneData.getId(); } public SceneType getSceneType() { return getSceneData().getSceneType(); } public int getPlayerCount() { return this.getPlayers().size(); } public GameEntity getEntityById(int id) { return this.entities.get(id); } public GameEntity getEntityByConfigId(int configId) { return this.entities.values().stream() .filter(x -> x.getConfigId() == configId) .findFirst() .orElse(null); } public GameEntity getEntityByConfigId(int configId, int groupId) { return this.entities.values().stream() .filter(x -> x.getConfigId() == configId && x.getGroupId() == groupId) .findFirst() .orElse(null); } @Nullable public Route getSceneRouteById(int routeId) { return sceneRoutes.get(routeId); } /** * Sets the scene's pause state. Sends the current scene's time to all players. * * @param paused The new pause state. */ public void setPaused(boolean paused) { if (this.isPaused != paused) { this.isPaused = paused; this.broadcastPacket(new PacketSceneTimeNotify(this)); } } /** * Gets the time in seconds since the scene started. * * @return The time in seconds since the scene started. */ public int getSceneTime() { return (int) (this.getWorld().getWorldTime() - this.startWorldTime); } /** * Gets {@link Scene#getSceneTime()} in seconds. * * @return The time in seconds since the scene started. */ public int getSceneTimeSeconds() { return this.getSceneTime() / 1000; } public void addDungeonSettleObserver(DungeonSettleListener dungeonSettleListener) { if (dungeonSettleListeners == null) { dungeonSettleListeners = new ArrayList<>(); } dungeonSettleListeners.add(dungeonSettleListener); } /** * Triggers an event in the dungeon manager. * * @param conditionType The condition type to trigger. * @param params The parameters to pass to the event. */ public void triggerDungeonEvent(DungeonPassConditionType conditionType, int... params) { if (this.dungeonManager == null) return; this.dungeonManager.triggerEvent(conditionType, params); } public boolean isInScene(GameEntity entity) { return this.entities.containsKey(entity.getId()); } public synchronized void addPlayer(Player player) { // Check if player already in if (getPlayers().contains(player)) { return; } // Remove player from prev scene if (player.getScene() != null) { player.getScene().removePlayer(player); } // Add getPlayers().add(player); player.setSceneId(this.getId()); player.setScene(this); this.setupPlayerAvatars(player); } public synchronized void removePlayer(Player player) { // Remove from challenge if leaving if (this.getChallenge() != null && this.getChallenge().inProgress()) { player.sendPacket(new PacketDungeonChallengeFinishNotify(this.getChallenge())); } // Remove player from scene getPlayers().remove(player); player.setScene(null); // Remove player avatars this.removePlayerAvatars(player); // Remove player gadgets for (EntityBaseGadget gadget : player.getTeamManager().getGadgets()) { this.removeEntity(gadget); } // Deregister scene if not in use if (this.getPlayerCount() <= 0 && !this.dontDestroyWhenEmpty) { this.getScriptManager().onDestroy(); this.getWorld().deregisterScene(this); } this.saveGroups(); } private void setupPlayerAvatars(Player player) { // Clear entities from old team player.getTeamManager().getActiveTeam().clear(); // Add new entities for player TeamInfo teamInfo = player.getTeamManager().getCurrentTeamInfo(); for (int avatarId : teamInfo.getAvatars()) { Avatar avatar = player.getAvatars().getAvatarById(avatarId); if (avatar == null) { if (player.getTeamManager().isUsingTrialTeam()) { avatar = player.getTeamManager().getTrialAvatars().get(avatarId); } if (avatar == null) continue; } player.getTeamManager().getActiveTeam().add(new EntityAvatar(player.getScene(), avatar)); } // Limit character index in case its out of bounds if (player.getTeamManager().getCurrentCharacterIndex() >= player.getTeamManager().getActiveTeam().size() || player.getTeamManager().getCurrentCharacterIndex() < 0) { player .getTeamManager() .setCurrentCharacterIndex(player.getTeamManager().getCurrentCharacterIndex() - 1); } } private synchronized void removePlayerAvatars(Player player) { var team = player.getTeamManager().getActiveTeam(); // removeEntities(team, VisionType.VISION_TYPE_REMOVE); // List isn't cool apparently // :( team.forEach(e -> removeEntity(e, VisionType.VISION_TYPE_REMOVE)); team.clear(); } public void spawnPlayer(Player player) { var teamManager = player.getTeamManager(); if (this.isInScene(teamManager.getCurrentAvatarEntity())) { return; } if (teamManager.getCurrentAvatarEntity().getFightProperty(FightProperty.FIGHT_PROP_CUR_HP) <= 0f) { teamManager.getCurrentAvatarEntity().setFightProperty(FightProperty.FIGHT_PROP_CUR_HP, 1f); } this.addEntity(teamManager.getCurrentAvatarEntity()); // Notify the client of any extra skill charges teamManager.getActiveTeam().stream() .map(EntityAvatar::getAvatar) .forEach(Avatar::sendSkillExtraChargeMap); } private void addEntityDirectly(GameEntity entity) { getEntities().put(entity.getId(), entity); entity.onCreate(); // Call entity create event } public synchronized void addEntity(GameEntity entity) { this.addEntityDirectly(entity); this.broadcastPacket(new PacketSceneEntityAppearNotify(entity)); } public synchronized void addEntityToSingleClient(Player player, GameEntity entity) { this.addEntityDirectly(entity); player.sendPacket(new PacketSceneEntityAppearNotify(entity)); } public void addDropEntity(GameItem item, GameEntity bornForm, Player player, boolean share) { // TODO:optimize EntityItem.java. Maybe we should make other players can't see // the ItemEntity. ItemData itemData = GameData.getItemDataMap().get(item.getItemId()); if (itemData == null) return; if (itemData.isEquip()) { float range = (1.5f + (.05f * item.getCount())); for (int j = 0; j < item.getCount(); j++) { Position pos = bornForm.getPosition().nearby2d(range).addY(0.5f); EntityItem entity = new EntityItem(this, player, itemData, pos, item.getCount(), share); addEntity(entity); } } else { EntityItem entity = new EntityItem( this, player, itemData, bornForm.getPosition().clone().addY(0.5f), item.getCount(), share); addEntity(entity); } } public void addEntities(Collection entities) { addEntities(entities, VisionType.VISION_TYPE_BORN); } private static List> chopped(List list, final int L) { List> parts = new ArrayList>(); final int N = list.size(); for (int i = 0; i < N; i += L) { parts.add(new ArrayList(list.subList(i, Math.min(N, i + L)))); } return parts; } public synchronized void addEntities( Collection entities, VisionType visionType) { if (entities == null || entities.isEmpty()) { return; } for (var entity : entities) { this.addEntityDirectly(entity); } for (var l : chopped(new ArrayList<>(entities), 100)) { this.broadcastPacket(new PacketSceneEntityAppearNotify(l, visionType)); } } private GameEntity removeEntityDirectly(GameEntity entity) { var removed = getEntities().remove(entity.getId()); if (removed != null) { removed.onRemoved(); // Call entity remove event } return removed; } public void removeEntity(GameEntity entity) { this.removeEntity(entity, VisionType.VISION_TYPE_DIE); } public synchronized void removeEntity(GameEntity entity, VisionType visionType) { GameEntity removed = this.removeEntityDirectly(entity); if (removed != null) { this.broadcastPacket(new PacketSceneEntityDisappearNotify(removed, visionType)); } } public void removeEntities(List entity, VisionType visionType) { var toRemove = entity.stream() .filter(Objects::nonNull) .map(this::removeEntityDirectly) .filter(Objects::nonNull) .toList(); if (toRemove.size() > 0) { this.broadcastPacket(new PacketSceneEntityDisappearNotify(toRemove, visionType)); } } public synchronized void replaceEntity(EntityAvatar oldEntity, EntityAvatar newEntity) { this.removeEntityDirectly(oldEntity); this.addEntityDirectly(newEntity); this.broadcastPacket( new PacketSceneEntityDisappearNotify(oldEntity, VisionType.VISION_TYPE_REPLACE)); this.broadcastPacket( new PacketSceneEntityAppearNotify( newEntity, VisionType.VISION_TYPE_REPLACE, oldEntity.getId())); } public void showOtherEntities(Player player) { GameEntity currentEntity = player.getTeamManager().getCurrentAvatarEntity(); List entities = this.getEntities().values().stream().filter(entity -> entity != currentEntity).toList(); player.sendPacket(new PacketSceneEntityAppearNotify(entities, VisionType.VISION_TYPE_MEET)); } public void handleAttack(AttackResult result) { // GameEntity attacker = getEntityById(result.getAttackerId()); GameEntity target = getEntityById(result.getDefenseId()); ElementType attackType = ElementType.getTypeByValue(result.getElementType()); if (target == null) { return; } // Godmode check if (target instanceof EntityAvatar) { if (((EntityAvatar) target).getPlayer().isInGodMode()) { return; } } // Sanity check target.damage(result.getDamage(), result.getAttackerId(), attackType); } public void killEntity(GameEntity target) { killEntity(target, 0); } public void killEntity(GameEntity target, int attackerId) { GameEntity attacker = null; if (attackerId > 0) { attacker = getEntityById(attackerId); } if (attacker != null) { // Check codex if (attacker instanceof EntityClientGadget gadgetAttacker) { var clientGadgetOwner = getEntityById(gadgetAttacker.getOwnerEntityId()); if (clientGadgetOwner instanceof EntityAvatar) { ((EntityClientGadget) attacker) .getOwner() .getCodex() .checkAnimal(target, CodexAnimalData.CountType.CODEX_COUNT_TYPE_KILL); } } else if (attacker instanceof EntityAvatar avatarAttacker) { avatarAttacker .getPlayer() .getCodex() .checkAnimal(target, CodexAnimalData.CountType.CODEX_COUNT_TYPE_KILL); } } // Packet this.broadcastPacket(new PacketLifeStateChangeNotify(attackerId, target, LifeState.LIFE_DEAD)); // Reward drop var world = this.getWorld(); if (target instanceof EntityMonster monster && this.getSceneType() != SceneType.SCENE_DUNGEON) { if (monster.getMetaMonster() != null && !world.getServer().getDropSystem().handleMonsterDrop(monster)) { Grasscutter.getLogger() .debug( "Can not solve monster drop: drop_id = {}, drop_tag = {}. Falling back to legacy drop system.", monster.getMetaMonster().drop_id, monster.getMetaMonster().drop_tag); getWorld().getServer().getDropSystemLegacy().callDrop(monster); } } // Remove entity from world this.removeEntity(target); // Death event target.onDeath(attackerId); this.triggerDungeonEvent( DungeonPassConditionType.DUNGEON_COND_KILL_MONSTER_COUNT, ++killedMonsterCount); } public void onTick() { // Disable ticking for the player's home world. if (this.getSceneType() == SceneType.SCENE_HOME_WORLD || this.getSceneType() == SceneType.SCENE_HOME_ROOM) { this.finishLoading(); return; } if (this.getScriptManager().isInit()) { // this.checkBlocks(); this.checkGroups(); } else { // TEMPORARY this.checkSpawns(); } // Triggers this.scriptManager.checkRegions(); if (challenge != null) { challenge.onCheckTimeOut(); } var sceneTime = getSceneTimeSeconds(); getEntities().forEach((eid, e) -> e.onTick(sceneTime)); blossomManager.onTick(); checkNpcGroup(); this.finishLoading(); this.checkPlayerRespawn(); if (this.tickCount++ % 10 == 0) this.broadcastPacket(new PacketSceneTimeNotify(this)); if (this.getPlayerCount() <= 0 && !this.dontDestroyWhenEmpty) { this.getScriptManager().onDestroy(); this.getWorld().deregisterScene(this); } } /** Validates a player's current position. Teleports the player if the player is out of bounds. */ private void checkPlayerRespawn() { if (this.getScriptManager().getConfig() == null) return; var diePos = this.getScriptManager().getConfig().die_y; // Check players in the scene. this.players.forEach( player -> { if (this.getScriptManager().getConfig() == null) return; // Check if we need a respawn if (diePos >= player.getPosition().getY()) { // Respawn the player. this.respawnPlayer(player); } }); // Check entities in the scene. this.getEntities() .forEach( (id, entity) -> { if (diePos >= entity.getPosition().getY()) { this.killEntity(entity); } }); } /** * @return The script's default location, or the player's location. */ public Position getDefaultLocation(Player player) { val defaultPosition = getScriptManager().getConfig().born_pos; return defaultPosition != null ? defaultPosition : player.getPosition(); } /** * @return The script's default rotation, or the player's rotation. */ public Position getDefaultRotation(Player player) { var defaultRotation = this.getScriptManager().getConfig().born_rot; return defaultRotation != null ? defaultRotation : player.getRotation(); } /** * Gets the respawn position for the player. * * @param player The player to get the respawn position for. * @return The respawn position for the player. */ private Position getRespawnLocation(Player player) { // TODO: Get the last valid location the player stood on. var lastCheckpointPos = dungeonManager != null ? dungeonManager.getRespawnLocation() : null; return lastCheckpointPos != null ? lastCheckpointPos : getDefaultLocation(player); } /** * Gets the respawn rotation for the player. * * @param player The player to get the respawn rotation for. * @return The respawn rotation for the player. */ private Position getRespawnRotation(Player player) { var lastCheckpointRot = this.dungeonManager != null ? this.dungeonManager.getRespawnRotation() : null; return lastCheckpointRot != null ? lastCheckpointRot : this.getDefaultRotation(player); } /** * Teleports the player to the respawn location. * * @param player The player to respawn. * @return true if the player was successfully respawned, false otherwise. */ public boolean respawnPlayer(Player player) { // Apply void damage as a penalty. player.getTeamManager().applyVoidDamage(); // TODO: Respawn the player at the last valid location. var targetPos = getRespawnLocation(player); var targetRot = getRespawnRotation(player); var teleportProps = TeleportProperties.builder() .sceneId(getId()) .teleportTo(targetPos) .teleportRot(targetRot) .teleportType(PlayerTeleportEvent.TeleportType.INTERNAL) .enterType(EnterTypeOuterClass.EnterType.ENTER_TYPE_GOTO) .enterReason( dungeonManager != null ? EnterReason.DungeonReviveOnWaypoint : EnterReason.Revival); return this.getWorld().transferPlayerToScene(player, teleportProps.build()); } /** * Invoked when the scene finishes loading. Runs all callbacks that were added with {@link * #runWhenFinished(Runnable)}. */ public void finishLoading() { if (this.finishedLoading) return; this.finishedLoading = true; this.afterLoadedCallbacks.forEach(Runnable::run); this.afterLoadedCallbacks.clear(); } /** * Adds a callback to be executed when the scene is finished loading. If the scene is already * finished loading, the callback will be executed immediately. * * @param runnable The callback to be executed. */ public void runWhenFinished(Runnable runnable) { if (this.isFinishedLoading()) { runnable.run(); return; } this.afterLoadedCallbacks.add(runnable); } public int getEntityLevel(int baseLevel, int worldLevelOverride) { int level = worldLevelOverride > 0 ? worldLevelOverride + baseLevel - 22 : baseLevel; level = Math.min(level, 100); level = level <= 0 ? 1 : level; return level; } public void checkNpcGroup() { Set npcBornEntries = ConcurrentHashMap.newKeySet(); for (Player player : this.getPlayers()) { npcBornEntries.addAll(loadNpcForPlayer(player)); } // clear the unreachable group for client var toUnload = this.npcBornEntrySet.stream() .filter(i -> !npcBornEntries.contains(i)) .map(SceneNpcBornEntry::getGroupId) .toList(); if (toUnload.size() > 0) { broadcastPacket(new PacketGroupUnloadNotify(toUnload)); Grasscutter.getLogger().debug("Unload NPC Group {}", toUnload); } // exchange the new npcBornEntry Set this.npcBornEntrySet = npcBornEntries; } public void checkSpawns() { Set loadedGridBlocks = new HashSet<>(); for (Player player : this.getPlayers()) { Collections.addAll( loadedGridBlocks, SpawnDataEntry.GridBlockId.getAdjacentGridBlockIds( player.getSceneId(), player.getPosition())); } if (this.loadedGridBlocks.containsAll( loadedGridBlocks)) { // Don't recalculate static spawns if nothing has changed return; } this.loadedGridBlocks = loadedGridBlocks; var spawnLists = GameDepot.getSpawnLists(); Set visible = new HashSet<>(); for (var block : loadedGridBlocks) { var spawns = spawnLists.get(block); if (spawns != null) { visible.addAll(spawns); } } // World level WorldLevelData worldLevelData = GameData.getWorldLevelDataMap().get(getWorld().getWorldLevel()); int worldLevelOverride = 0; if (worldLevelData != null) { worldLevelOverride = worldLevelData.getMonsterLevel(); } // Todo List toAdd = new ArrayList<>(); List toRemove = new ArrayList<>(); var spawnedEntities = this.getSpawnedEntities(); for (SpawnDataEntry entry : visible) { // If spawn entry is in our view and hasnt been spawned/killed yet, we should spawn it if (!spawnedEntities.contains(entry) && !this.getDeadSpawnedEntities().contains(entry)) { // Entity object holder GameEntity entity = null; // Check if spawn entry is monster or gadget if (entry.getMonsterId() > 0) { MonsterData data = GameData.getMonsterDataMap().get(entry.getMonsterId()); if (data == null) continue; int level = this.getEntityLevel(entry.getLevel(), worldLevelOverride); EntityMonster monster = new EntityMonster(this, data, entry.getPos(), level); monster.getRotation().set(entry.getRot()); monster.setGroupId(entry.getGroup().getGroupId()); monster.setPoseId(entry.getPoseId()); monster.setConfigId(entry.getConfigId()); monster.setSpawnEntry(entry); entity = monster; } else if (entry.getGadgetId() > 0) { EntityGadget gadget = new EntityGadget(this, entry.getGadgetId(), entry.getPos(), entry.getRot()); gadget.setGroupId(entry.getGroup().getGroupId()); gadget.setConfigId(entry.getConfigId()); gadget.setSpawnEntry(entry); int state = entry.getGadgetState(); if (state > 0) { gadget.setState(state); } gadget.buildContent(); gadget.setFightProperty(FightProperty.FIGHT_PROP_BASE_HP, Float.POSITIVE_INFINITY); gadget.setFightProperty(FightProperty.FIGHT_PROP_CUR_HP, Float.POSITIVE_INFINITY); gadget.setFightProperty(FightProperty.FIGHT_PROP_MAX_HP, Float.POSITIVE_INFINITY); entity = gadget; blossomManager.initBlossom(gadget); } if (entity == null) continue; // Add to scene and spawned list toAdd.add(entity); spawnedEntities.add(entry); } } for (GameEntity entity : this.getEntities().values()) { var spawnEntry = entity.getSpawnEntry(); if (spawnEntry != null && !visible.contains(spawnEntry)) { toRemove.add(entity); spawnedEntities.remove(spawnEntry); } } if (toAdd.size() > 0) { toAdd.forEach(this::addEntityDirectly); this.broadcastPacket(new PacketSceneEntityAppearNotify(toAdd, VisionType.VISION_TYPE_BORN)); } if (toRemove.size() > 0) { toRemove.forEach(this::removeEntityDirectly); this.broadcastPacket( new PacketSceneEntityDisappearNotify(toRemove, VisionType.VISION_TYPE_REMOVE)); blossomManager.recycleGadgetEntity(toRemove); } } public List getPlayerActiveBlocks(Player player) { // consider the borders' entities of blocks, so we check if contains by index return SceneIndexManager.queryNeighbors( getScriptManager().getBlocksIndex(), player.getPosition().toXZDoubleArray(), Grasscutter.getConfig().server.game.loadEntitiesForPlayerRange); } public Set getPlayerActiveGroups(Player player) { // consider the borders' entities of blocks, so we check if contains by index Position playerPosition = player.getPosition(); Set activeGroups = new HashSet<>(); for (int i = 0; i < 4; i++) { Grid grid = getScriptManager().getGroupGrids().get(i); activeGroups.addAll(grid.getNearbyGroups(i, playerPosition)); } return activeGroups; } public boolean loadBlock(SceneBlock block) { if (this.loadedBlocks.contains(block)) return false; this.onLoadBlock(block, this.players); this.loadedBlocks.add(block); return true; } public void checkGroups() { Set visible = this.players.stream() .map(this::getPlayerActiveGroups) .flatMap(Collection::stream) .collect(Collectors.toSet()); for (var group : this.loadedGroups) { if (!visible.contains(group.id) && !group.dynamic_load) unloadGroup(scriptManager.getBlocks().get(group.block_id), group.id); } var toLoad = visible.stream() .filter(g -> this.loadedGroups.stream().noneMatch(gr -> gr.id == g)) .map( g -> { for (var b : scriptManager.getBlocks().values()) { loadBlock(b); SceneGroup group = b.groups.getOrDefault(g, null); if (group != null && !group.dynamic_load) return group; } return null; }) .filter(Objects::nonNull) .toList(); this.onLoadGroup(toLoad); if (!toLoad.isEmpty()) this.onRegisterGroups(); } public void onLoadBlock(SceneBlock block, List players) { this.getScriptManager().loadBlockFromScript(block); scriptManager.getLoadedGroupSetPerBlock().put(block.id, new HashSet<>()); Grasscutter.getLogger().trace("Scene {} block {} loaded.", this.getId(), block.id); } public int loadDynamicGroup(int group_id) { SceneGroup group = getScriptManager().getGroupById(group_id); if (group == null || getScriptManager().getGroupInstanceById(group_id) != null) return -1; // Group not found or already instanced this.onLoadGroup(new ArrayList<>(List.of(group))); if (GameData.getGroupReplacements().containsKey(group_id)) onRegisterGroups(); if (group.init_config == null) return -1; return group.init_config.suite; } public boolean unregisterDynamicGroup(int groupId) { var group = getScriptManager().getGroupById(groupId); if (group == null) return false; var block = getScriptManager().getBlocks().get(group.block_id); this.unloadGroup(block, groupId); return true; } public void onRegisterGroups() { var sceneGroups = this.loadedGroups; var sceneGroupMap = sceneGroups.stream().collect(Collectors.toMap(item -> item.id, item -> item)); var sceneGroupsIds = sceneGroups.stream().map(group -> group.id).toList(); var dynamicGroups = sceneGroups.stream().filter(group -> group.dynamic_load).map(group -> group.id).toList(); // Create the graph var nodes = new ArrayList(); var groupList = new ArrayList(); GameData.getGroupReplacements().values().stream() .filter(replacement -> dynamicGroups.contains(replacement.id)) .forEach( replacement -> { Grasscutter.getLogger().debug("Graph ordering replacement {}", replacement); replacement.replace_groups.forEach( group -> { nodes.add(new KahnsSort.Node(replacement.id, group)); if (!groupList.contains(group)) groupList.add(group); }); if (!groupList.contains(replacement.id)) groupList.add(replacement.id); }); KahnsSort.Graph graph = new KahnsSort.Graph(nodes, groupList); List dynamicGroupsOrdered = KahnsSort.doSort(graph); // Now we can start unloading and loading groups :D dynamicGroupsOrdered.forEach( group -> { if (GameData.getGroupReplacements().containsKey((int) group)) { // isGroupJoinReplacement var data = GameData.getGroupReplacements().get((int) group); var sceneGroupReplacement = this.loadedGroups.stream().filter(g -> g.id == group).findFirst().orElseThrow(); if (sceneGroupReplacement.is_replaceable != null) { var it = data.replace_groups.iterator(); while (it.hasNext()) { var replace_group = it.next(); if (!sceneGroupsIds.contains(replace_group)) continue; // Check if we can replace this group SceneGroup sceneGroup = sceneGroupMap.get(replace_group); if (sceneGroup != null && sceneGroup.is_replaceable != null && ((sceneGroup.is_replaceable.value && sceneGroup.is_replaceable.version <= sceneGroupReplacement.is_replaceable.version) || sceneGroup.is_replaceable.new_bin_only)) { this.unloadGroup( scriptManager.getBlocks().get(sceneGroup.block_id), replace_group); it.remove(); Grasscutter.getLogger().debug("Graph ordering: unloaded {}", replace_group); } } } } }); } public void loadTriggerFromGroup(SceneGroup group, String triggerName) { // Load triggers and regions this.getScriptManager() .registerTrigger( group.triggers.values().stream() .filter(p -> p.getName().contains(triggerName)) .toList()); group.regions.values().stream() .filter(q -> q.config_id == Integer.parseInt(triggerName.substring(13))) .map(region -> new EntityRegion(this, region)) .forEach(getScriptManager()::registerRegion); } public void onLoadGroup(List groups) { if (groups == null || groups.isEmpty()) { return; } for (var group : groups) { if (this.loadedGroups.contains(group)) continue; // We load the script files for the groups here this.getScriptManager().loadGroupFromScript(group); if (!this.scriptManager.getLoadedGroupSetPerBlock().containsKey(group.block_id)) this.onLoadBlock(scriptManager.getBlocks().get(group.block_id), players); this.scriptManager.getLoadedGroupSetPerBlock().get(group.block_id).add(group); } // Spawn gadgets AFTER triggers are added // TODO var entities = new ArrayList(); for (var group : groups) { if (this.loadedGroups.contains(group)) continue; if (group.init_config == null) { continue; } var groupInstance = this.getScriptManager().getGroupInstanceById(group.id); var cachedInstance = this.getScriptManager().getCachedGroupInstanceById(group.id); if (cachedInstance != null) { cachedInstance.setLuaGroup(group); groupInstance = cachedInstance; } // Load suites // int suite = group.findInitSuiteIndex(0); this.getScriptManager() .refreshGroup(groupInstance, 0, false); // This is what the official server does this.loadedGroups.add(group); } this.scriptManager.meetEntities(entities); groups.forEach( g -> scriptManager.callEvent(new ScriptArgs(g.id, EventType.EVENT_GROUP_LOAD, g.id))); Grasscutter.getLogger().debug("Scene {} loaded {} group(s)", this.getId(), groups.size()); } public void unloadGroup(SceneBlock block, int group_id) { List toRemove = this.getEntities().values().stream() .filter(e -> e != null && (e.getBlockId() == block.id && e.getGroupId() == group_id)) .toList(); if (toRemove.size() > 0) { toRemove.forEach(this::removeEntityDirectly); this.broadcastPacket( new PacketSceneEntityDisappearNotify(toRemove, VisionType.VISION_TYPE_REMOVE)); } var group = block.groups.get(group_id); if (group.triggers != null) { group.triggers.values().forEach(getScriptManager()::deregisterTrigger); } if (group.regions != null) { group.regions.values().forEach(getScriptManager()::deregisterRegion); } scriptManager.getLoadedGroupSetPerBlock().get(block.id).remove(group); this.loadedGroups.remove(group); if (this.scriptManager.getLoadedGroupSetPerBlock().get(block.id).isEmpty()) { this.scriptManager.getLoadedGroupSetPerBlock().remove(block.id); Grasscutter.getLogger().trace("Scene {} block {} is unloaded.", this.getId(), block.id); } this.broadcastPacket(new PacketGroupUnloadNotify(List.of(group_id))); this.scriptManager.unregisterGroup(group); } // Gadgets public void onPlayerCreateGadget(EntityClientGadget gadget) { // Directly add this.addEntityDirectly(gadget); // Add to owner's gadget list gadget.getOwner().getTeamManager().getGadgets().add(gadget); // Optimization if (this.getPlayerCount() == 1 && this.getPlayers().get(0) == gadget.getOwner()) { return; } this.broadcastPacketToOthers(gadget.getOwner(), new PacketSceneEntityAppearNotify(gadget)); } public void onPlayerDestroyGadget(int entityId) { GameEntity entity = getEntities().get(entityId); if (!(entity instanceof EntityClientGadget gadget)) { return; } // Get and remove entity this.removeEntityDirectly(gadget); // Remove from owner's gadget list gadget.getOwner().getTeamManager().getGadgets().remove(gadget); // Optimization if (this.getPlayerCount() == 1 && this.getPlayers().get(0) == gadget.getOwner()) { return; } this.broadcastPacketToOthers( gadget.getOwner(), new PacketSceneEntityDisappearNotify(gadget, VisionType.VISION_TYPE_DIE)); } // Broadcasting public void broadcastPacket(BasePacket packet) { // Send to all players - might have to check if player has been sent data packets for (Player player : this.getPlayers()) { player.getSession().send(packet); } } public void broadcastPacketToOthers(Player excludedPlayer, BasePacket packet) { // Optimization if (this.getPlayerCount() == 1 && this.getPlayers().get(0) == excludedPlayer) { return; } // Send to all players - might have to check if player has been sent data packets for (Player player : this.getPlayers()) { if (player == excludedPlayer) { continue; } // Send player.getSession().send(packet); } } public void addItemEntity(int itemId, int amount, GameEntity bornForm) { ItemData itemData = GameData.getItemDataMap().get(itemId); if (itemData == null) { return; } if (itemData.isEquip()) { float range = (1.5f + (.05f * amount)); for (int i = 0; i < amount; i++) { Position pos = bornForm.getPosition().nearby2d(range).addZ(.9f); // Why Z? EntityItem entity = new EntityItem(this, null, itemData, pos, 1); addEntity(entity); } } else { EntityItem entity = new EntityItem( this, null, itemData, bornForm.getPosition().clone().addZ(.9f), amount); // Why Z? addEntity(entity); } } public void loadNpcForPlayerEnter(Player player) { this.npcBornEntrySet.addAll(loadNpcForPlayer(player)); } private List loadNpcForPlayer(Player player) { var pos = player.getPosition(); var data = GameData.getSceneNpcBornData().get(getId()); if (data == null) { return List.of(); } var npcList = SceneIndexManager.queryNeighbors( data.getIndex(), pos.toDoubleArray(), Grasscutter.getConfig().server.game.loadEntitiesForPlayerRange); var sceneNpcBornEntries = npcList.stream().filter(i -> !this.npcBornEntrySet.contains(i)).toList(); if (sceneNpcBornEntries.size() > 0) { this.broadcastPacket(new PacketGroupSuiteNotify(sceneNpcBornEntries)); Grasscutter.getLogger().debug("Loaded Npc Group Suite {}", sceneNpcBornEntries); } return npcList; } public void loadGroupForQuest(List sceneGroupSuite) { if (!scriptManager.isInit()) { return; } sceneGroupSuite.forEach( i -> { var group = scriptManager.getGroupById(i.getGroup()); if (group == null) return; var groupInstance = scriptManager.getGroupInstanceById(i.getGroup()); var suite = group.getSuiteByIndex(i.getSuite()); if (suite == null || groupInstance == null) { return; } scriptManager.refreshGroup(groupInstance, i.getSuite(), false); }); } /** * Adds an unlocked force to the scene. * * @param force The ID of the force to unlock. */ public void unlockForce(int force) { this.unlockedForces.add(force); this.broadcastPacket(new PacketSceneForceUnlockNotify(force, true)); } /** * Removes an unlocked force from the scene. * * @param force The ID of the force to lock. */ public void lockForce(int force) { this.unlockedForces.remove(force); this.broadcastPacket(new PacketSceneForceLockNotify(force)); } public void selectWorktopOptionWith(SelectWorktopOptionReqOuterClass.SelectWorktopOptionReq req) { GameEntity entity = getEntityById(req.getGadgetEntityId()); if (entity == null) { return; } // Handle if (entity instanceof EntityGadget gadget) { if (gadget.getContent() instanceof GadgetWorktop worktop) { boolean shouldDelete = worktop.onSelectWorktopOption(req); if (shouldDelete) { entity.getScene().removeEntity(entity, VisionType.VISION_TYPE_REMOVE); } } } } public void saveGroups() { this.getScriptManager().getCachedGroupInstances().values().forEach(SceneGroupInstance::save); } }