feature(custom_command): add areacontrol command

feature(areacontrol):
- cubic detection
- auto-change player gamemode
This commit is contained in:
2025-11-11 12:40:50 +08:00
parent b68114fc31
commit 9cf5c7c4bb
2 changed files with 873 additions and 0 deletions

168
docs/areacontrol.md Normal file
View File

@@ -0,0 +1,168 @@
# AreaControl 脚本 MDocs
## 1. 概述
**AreaControl** 是一个为 Minecraft 服务器设计的强大区域管理工具,通过 KubeJS 实现。它允许管理员在游戏世界中定义一个特殊区域,并对进入该区域的玩家施加特定的规则,例如自动切换游戏模式、限制物品使用等。
该脚本的核心设计理念是 **高性能****易用性**。它采用事件驱动和多种优化技术,确保在不牺牲服务器性能的前提下,提供稳定可靠的功能。本文档将为您提供从安装、使用到二次开发的全方位指南。
---
## 2. 功能特性
- **自动模式切换**:玩家进入指定区域时,自动切换为**冒险**或**旁观**模式;离开时恢复为**生存**模式。
- **动态白名单**:所有功能仅对白名单内的玩家生效,管理员可通过命令随时增删玩家。
- **物品冷却系统**:可以为区域内的玩家设置统一的物品使用冷却时间。
- **实时启/禁用**:管理员可通过一条简单命令,在不重启服务器的情况下,全局启用或禁用所有功能。
- **二维平面检测**区域检测仅基于水平坐标X 和 Z 轴忽略玩家的高度Y 轴),适用于各种地形。
- **高性能设计**基于事件驱动无不必要的循环tick-polling确保对服务器的性能影响降至最低。
- **配置持久化**:所有配置(如区域中心、半径、白名单)都会自动保存,服务器重启后无需重新设置。
---
## 3. 用户指南
本节面向服务器管理员和普通用户,指导您如何安装和使用 AreaControl。
### 3.1. 安装
1. 确保您的 Minecraft 服务器已经正确安装了 KubeJS Mod。
2. 将编译后的 `areacontrol.js` 脚本文件放置在服务器的 `kubejs/server_scripts/` 目录下。
3. 重新启动服务器或在游戏内执行 `/kubejs reload server_scripts`
脚本加载后将自动初始化,默认在世界中心 `(0, 0)` 创建一个半径为 `50` 格的区域。
### 3.2. 管理命令
您可以在游戏内通过 `areacontrol` 系列命令来管理脚本。所有命令都需要管理员权限。
* `/areacontrol status`:查看脚本当前配置。
* `/areacontrol toggle`:全局启用或禁用功能。
* `/areacontrol setcenter`:将当前位置设为区域中心。
* `/areacontrol setradius <半径>`:设置区域半径。
* `/areacontrol whitelist <add|remove|list> [玩家名]`:管理白名单。
---
## 4. 开发者参考:技术与定制
本节面向希望理解其工作原理或进行二次开发的开发者。
### 4.1. 开发环境与构建
本项目使用 **TypeScript** 编写,以获得更强的类型安全和代码可维护性。
- **源码目录**:所有服务器端脚本的 TypeScript 源文件位于 `src/server_scripts/` 目录下。
- **编译**:在发布或测试前,需要将 TypeScript (`.ts`) 文件编译为 KubeJS 可识别的 JavaScript (`.js`) 文件。
- **单次编译**:执行 `npm run tsc``npx tsc --project tsconfig.server.json`
- **监视模式**:在开发过程中,建议使用监视模式,它会在文件发生变化时自动重新编译。执行 `npm run watch::server` 即可。
- **类型定义**:项目依赖于 KubeJS Probe 生成的类型定义,位于 `types/probe-types/` 目录,这为开发提供了完整的代码提示和类型检查。
### 4.2. 设计理念
#### 事件驱动架构
脚本不使用高开销的 `tick` 轮询,而是监听特定玩家事件来触发逻辑。
- `PlayerEvents.loggedIn`: 玩家登录时加入检查。
- `PlayerEvents.loggedOut`: 玩家登出时清理其缓存数据。
- `PlayerEvents.tick`: **降频使用**,每秒检查一次玩家位置。
- `ItemEvents.use`: 在玩家使用物品时触发冷却逻辑。
```typescript
// 示例:利用类型定义,精确捕获事件和玩家对象
ItemEvents.use((event: Internal.ItemUseEvent) => {
const { player } = event;
if (player && shouldApplyCooldown(player)) {
// ...
}
});
```
#### 分层与状态缓存
为避免不必要的操作,脚本采用分层检查和状态缓存。
1. **一级过滤**:检查脚本是否启用、玩家是否在白名单内。
2. **二级检查(降频)**:每秒检查一次玩家是否在区域内。
3. **三级处理(状态驱动)**:仅当玩家**跨越区域边界**时,才执行核心操作并更新缓存。
```typescript
// playerStates 缓存玩家的当前状态,并提供类型安全
const playerStates = new Map<string, { inArea: boolean }>();
function optimizedPlayerCheck(player: Internal.Player): void {
const currentState = playerStates.get(player.uuid) ?? { inArea: false };
const isInArea = isPlayerInArea(player);
if (currentState.inArea !== isInArea) {
handleGameModeChange(player, isInArea);
playerStates.set(player.uuid, { inArea: isInArea });
}
}
```
### 4.3. 核心算法
#### 快速边界检测
脚本通过**预计算**区域的边界框Bounding Box来实现高效的位置判断。
```typescript
// 1. 初始化时预计算边界
function updateBounds(center: Internal.Vec3i, radius: number): void {
bounds.minX = center.x - radius;
bounds.maxX = center.x + radius;
bounds.minZ = center.z - radius;
bounds.maxZ = center.z + radius;
}
// 2. 运行时进行高效比较
function isPlayerInArea(player: Internal.Player): boolean {
const pos = player.blockPosition();
return pos.x >= bounds.minX && pos.x <= bounds.maxX &&
pos.z >= bounds.minZ && pos.z <= bounds.maxZ;
}
```
#### 内存自动清理
通过监听 `PlayerEvents.loggedOut` 事件,自动从 `playerStates` 缓存中删除下线玩家的数据,防止内存泄漏。
### 4.4. 数据结构
我们使用 TypeScript 的 `interface` 来定义核心数据结构,确保类型安全。
#### 配置 (AreaControlConfig)
```typescript
interface AreaControlConfig {
enabled: boolean;
center: { x: number; y: number; z: number };
radius: number;
whitelist: string[];
mode: 'adventure' | 'spectator';
cooldownTime: number; // In ticks
}
```
#### 缓存 (PlayerState)
```typescript
interface PlayerState {
inArea: boolean;
}
// 最终的缓存结构
const playerStates: Map<string, PlayerState>; // Key: Player UUID
```
### 4.5. 定制化
#### 调整检查频率
默认检查频率是每秒一次(`20 ticks`)。您可以根据服务器需求调整此值。
```typescript
// 在 PlayerEvents.tick 监听器中调整
PlayerEvents.tick((event: Internal.PlayerTickEvent) => {
const player = event.player;
// 将 20 修改为您希望的检查间隔ticks
if (player.age % 20 === 0) {
optimizedPlayerCheck(player);
}
});
```
- **建议值**`10`(半秒一次,响应快),`40`(两秒一次,开销低)。不建议低于 `10`

View File

@@ -0,0 +1,705 @@
// AreaControl - Advanced Area Management System for KubeJS
// Event-driven architecture with high performance optimization
// ==================== TYPE DEFINITIONS ====================
/**
* @typedef {object} AreaControlConfig
* @property {boolean} enabled
* @property {{x: number, y: number, z: number}} center
* @property {number} radius
* @property {string[]} whitelist
* @property {"adventure" | "spectator"} mode
* @property {number} cooldownSecs
*/
/**
* @typedef {object} AreaBounds
* @property {number} minX
* @property {number} maxX
* @property {number} minZ
* @property {number} maxZ
*/
/**
* @typedef {typeof Internal.HashMap} HashMap
*/
// ==================== GLOBAL CONSTANTS ====================
const SECOND_TICKS = 20;
const CONFIG_FILE = "areacontrol_config.json";
const CHECK_FREQUENCY = 20; // ticks (1 second)
// ==================== STATE MANAGEMENT ====================
/**
* Default configuration
* @type {AreaControlConfig}
*/
let config = {
enabled: true,
center: { x: 0, y: 0, z: 0 },
radius: 5,
whitelist: [],
mode: "adventure",
cooldownSecs: 10 * SECOND_TICKS, // 60 seconds
};
/**
* Pre-calculated bounds for O(1) area checking
* @type {AreaBounds}
*/
const bounds = {
minX: -50,
maxX: 50,
minZ: -50,
maxZ: 50,
};
/**
* Player state cache - prevents unnecessary operations
* @type {{[key: string]: boolean | undefined}}
*/
const playerStates = {};
/**
* Item cooldown tracking
* @type {{[key: string]: number | undefined}}
*/
const playerCooldowns = {};
// ==================== UTILITY FUNCTIONS ====================
/**
* Update area bounds based on center and radius
* Pre-calculates boundaries for efficient checking
* @param {{x: number, y: number, z: number}} center
* @param {number} radius
* @returns {void}
*/
function updateBounds(center, radius) {
bounds.minX = center.x - radius;
bounds.maxX = center.x + radius;
bounds.minZ = center.z - radius;
bounds.maxZ = center.z + radius;
console.log(
`[AreaControl] Updated bounds: X(${String(bounds.minX)} to ${String(bounds.maxX)}), Z(${String(bounds.minZ)} to ${String(bounds.maxZ)})`,
);
}
/**
* Fast 2D area boundary check (ignores Y coordinate)
* Uses pre-calculated bounds for O(1) performance
* @param {number} x
* @param {number} z
* @returns {boolean}
*/
function isPositionInArea(x, z) {
return (
x >= bounds.minX &&
x <= bounds.maxX &&
z >= bounds.minZ &&
z <= bounds.maxZ
);
}
/**
* Check if player is whitelisted for area control
* @param {string} playerName
* @returns {boolean}
*/
function isPlayerWhitelisted(playerName) {
return config.whitelist.indexOf(playerName) !== -1;
}
/**
* Handle player entering the protected area
* @param {Internal.Player} player
* @returns {void}
*/
function handlePlayerEnterArea(player) {
// Apply configured game mode
if (config.mode === "adventure") {
Utils.getServer().getPlayer(player.stringUuid).setGameMode("adventure");
} else {
Utils.getServer().getPlayer(player.stringUuid).setGameMode("spectator");
}
// Send notification
player.tell(
/** @type {any} */ (
Component.string(
"§6[AreaControl] §eEntered protected area. Game mode changed.",
)
),
);
}
/**
* Handle player leaving the protected area
* @param {Internal.Player} player
* @returns {void}
*/
function handlePlayerLeaveArea(player) {
// Restore survival mode
Utils.getServer().getPlayer(player.stringUuid).setGameMode("survival");
// Send notification
player.tell(
/** @type {any} */ (
Component.string(
"§6[AreaControl] §eLeft protected area. Game mode restored.",
)
),
);
}
/**
* Optimized player area check with state caching
* Only triggers changes when crossing area boundaries
* @param {Internal.Player} player
* @returns {void}
*/
function checkPlayerAreaStatus(player) {
if (!config.enabled || !isPlayerWhitelisted(player.username)) {
return;
}
const pos = player.blockPosition();
const isCurrentlyInArea = isPositionInArea(pos.x, pos.z);
const playerId = player.stringUuid;
const cachedState = playerStates[playerId];
// Only process if state changed or first check
if (cachedState !== isCurrentlyInArea) {
if (isCurrentlyInArea) {
handlePlayerEnterArea(player);
} else if (cachedState === true) {
// Only trigger leave if we were previously in area
handlePlayerLeaveArea(player);
}
// Update cached state
playerStates[playerId] = isCurrentlyInArea;
}
}
/**
* Check if item cooldown should be applied
* @param {Internal.Player} player
* @returns {boolean}
*/
function shouldApplyItemCooldown(player) {
if (!config.enabled || !isPlayerWhitelisted(player.username)) {
return false;
}
const playerState = playerStates[player.stringUuid];
return playerState === undefined || playerState === true;
}
/**
* Save configuration to persistent storage
* @returns {void}
*/
function saveConfiguration() {
const server = Utils.server;
try {
// Use KubeJS persistent data instead of JsonIO
if (server.persistentData.contains(CONFIG_FILE)) {
server.persistentData.put(CONFIG_FILE, NBT.toTag(config));
console.log("[AreaControl] Configuration saved successfully");
}
} catch (error) {
console.warn(`[AreaControl] Failed to save configuration:${error}`);
}
}
/**
* Load configuration from persistent storage
* @returns {void}
*/
function loadConfiguration() {
const server = Utils.server;
try {
if (server.persistentData.contains(CONFIG_FILE)) {
const savedData = server.persistentData.get(CONFIG_FILE);
if (typeof savedData === "string") {
const loadedConfig = JSON.parse(savedData);
config = Object.assign(config, loadedConfig);
updateBounds(config.center, config.radius);
console.log("[AreaControl] Configuration loaded from file");
} else {
updateBounds(config.center, config.radius);
saveConfiguration(); // Create initial config
console.log("[AreaControl] Created default configuration");
}
}
} catch (error) {
console.warn(
`[AreaControl] Failed to load configuration, using defaults: ${error}`,
);
updateBounds(config.center, config.radius);
}
}
/**
* Register all event handlers
* @returns {void}
*/
function registerEventHandlers() {
/**
* @param {Internal.PlayerEvent.LoggedIn} event
*/
PlayerEvents.loggedIn((event) => {
const { player } = event;
const pos = player.blockPosition();
const isInArea = isPositionInArea(pos.x, pos.z);
playerStates[player.stringUuid] = isInArea;
// Apply immediate game mode if in area
if (isInArea && config.enabled) {
handlePlayerEnterArea(player);
}
console.log(
`[AreaControl] Player ${player.username} logged in, in area: ${String(isInArea)}`,
);
});
/**
* @param {Internal.PlayerEvent.LoggedOut} event
*/
PlayerEvents.loggedOut((event) => {
const { player } = event;
const playerId = player.stringUuid;
delete playerStates[playerId];
delete playerCooldowns[playerId];
console.log(
`[AreaControl] Cleaned up data for player ${player.username}`,
);
});
/**
* @param {Internal.PlayerEvent.Tick} event
*/
PlayerEvents.tick((event) => {
const { player } = event;
// Check every CHECK_FREQUENCY ticks for performance
if (player.age % CHECK_FREQUENCY === 0) {
checkPlayerAreaStatus(player);
}
});
/**
* @param {Internal.LivingEntityUseItemEvent$Finish} event
*/
// ForgeEvents.onEvent(
// "net.minecraftforge.event.entity.living.LivingEntityUseItemEvent$Finish",
// (event) => {
// const { item: itemStack, entity } = event;
// if (!entity.isPlayer()) return;
// const player = Utils.server.getPlayer(entity.stringUuid);
// if (player === undefined || player === null) return;
// const item = itemStack.getItem();
// const itemsCooldowns = player.getCooldowns();
// if (
// shouldApplyItemCooldown(player) &&
// !itemsCooldowns.isOnCooldown(item)
// ) {
// itemsCooldowns.addCooldown(item, config.cooldownSecs);
// }
// },
// );
}
/**
* Register command system
* @returns {void}
*/
function registerCommands() {
/**
* @param {Internal.ServerCommandEvent} event
*/
ServerEvents.commandRegistry((event) => {
const { commands, arguments: Arguments } = event;
/**
* @param {any} ctx
* @returns {number}
*/
const statusCommand = (ctx) => {
const source = ctx.source;
source.sendSuccess("§6[AreaControl] Current Status:", false);
source.sendSuccess(`§e- Enabled: ${String(config.enabled)}`, false);
source.sendSuccess(
`§e- Center: (${String(config.center.x)}, ${String(config.center.y)}, ${String(config.center.z)})`,
false,
);
source.sendSuccess(`§e- Radius: ${String(config.radius)}`, false);
source.sendSuccess(`§e- Mode: ${config.mode}`, false);
source.sendSuccess(
`§e- Whitelist: ${String(config.whitelist.length)} players`,
false,
);
source.sendSuccess(
`§e- Cooldown: ${String(config.cooldownSecs)} Ticks (${String(config.cooldownSecs / SECOND_TICKS)}s)`,
false,
);
source.sendSuccess(
`§e- Active players: ${String(Object.keys(playerStates).length)}`,
false,
);
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const toggleCommand = (ctx) => {
config.enabled = !config.enabled;
saveConfiguration();
ctx.source.sendSuccess(
config.enabled
? "§6[AreaControl] §aEnabled"
: "§6[AreaControl] §cDisabled",
true,
);
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const setCenterCommand = (ctx) => {
const source = ctx.source;
if (!source.player) {
source.sendFailure("§cThis command must be run by a player");
return 0;
}
const pos = source.player.blockPosition();
config.center = { x: pos.x, y: pos.y, z: pos.z };
updateBounds(config.center, config.radius);
saveConfiguration();
source.sendSuccess(
`§6[AreaControl] §eCenter set to (${String(pos.x)}, ${String(pos.y)}, ${String(pos.z)})`,
true,
);
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const setRadiusCommand = (ctx) => {
const radius = Arguments.INTEGER.getResult(ctx, "radius");
if (radius < 1 || radius > 1000) {
ctx.source.sendFailure("§cRadius must be between 1 and 1000");
return 0;
}
config.radius = radius;
updateBounds(config.center, config.radius);
saveConfiguration();
ctx.source.sendSuccess(
`§6[AreaControl] §eRadius set to ${String(radius)}`,
true,
);
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const setModeCommand = (ctx) => {
const mode = Arguments.STRING.getResult(ctx, "mode");
if (mode !== "adventure" && mode !== "spectator") {
ctx.source.sendFailure(
'§cMode must be either "adventure" or "spectator"',
);
return 0;
}
config.mode = mode;
saveConfiguration();
ctx.source.sendSuccess(
`§6[AreaControl] §eArea mode set to ${mode}`,
true,
);
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const setCooldownCommand = (ctx) => {
const cooldown = Arguments.INTEGER.getResult(ctx, "cooldown");
if (cooldown < 0) {
ctx.source.sendFailure(
"§cCooldown must be a non-negative number",
);
return 0;
}
config.cooldownSecs = cooldown;
saveConfiguration();
ctx.source.sendSuccess(
`§6[AreaControl] §eItem cooldown set to ${String(cooldown)} ticks (${String(cooldown / 20)}s)`,
true,
);
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const whitelistAddCommand = (ctx) => {
const playerName = Arguments.STRING.getResult(ctx, "player");
if (config.whitelist.indexOf(playerName) === -1) {
config.whitelist.push(playerName);
saveConfiguration();
ctx.source.sendSuccess(
`§6[AreaControl] §eAdded ${playerName} to whitelist`,
true,
);
} else {
ctx.source.sendFailure(
`§c${playerName} is already whitelisted`,
);
}
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const whitelistRemoveCommand = (ctx) => {
const playerName = Arguments.STRING.getResult(ctx, "player");
const index = config.whitelist.indexOf(playerName);
if (index !== -1) {
config.whitelist.splice(index, 1);
// Clean up player state if they're removed
const server = ctx.source.server;
const onlinePlayer = server.players.find(
/**
* @param {Internal.Player} p
* @returns {boolean}
*/
(p) => p.username === playerName,
);
if (onlinePlayer) {
delete playerStates[onlinePlayer.stringUUID];
delete playerCooldowns[onlinePlayer.stringUUID];
}
saveConfiguration();
ctx.source.sendSuccess(
`§6[AreaControl] §eRemoved ${playerName} from whitelist`,
true,
);
} else {
ctx.source.sendFailure(`§c${playerName} is not whitelisted`);
}
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const whitelistListCommand = (ctx) => {
const source = ctx.source;
if (config.whitelist.length === 0) {
source.sendSuccess(
"§6[AreaControl] §eWhitelist is empty",
false,
);
} else {
source.sendSuccess(
"§6[AreaControl] §eWhitelisted players:",
false,
);
config.whitelist.forEach((playerName) => {
source.sendSuccess(`§e- ${playerName}`, false);
});
}
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const reloadCommand = (ctx) => {
loadConfiguration();
ctx.source.sendSuccess(
"§6[AreaControl] §aConfiguration reloaded",
true,
);
return 1;
};
/**
* @param {any} ctx
* @returns {number}
*/
const helpCommand = (ctx) => {
const source = ctx.source;
source.sendFailure("§cAvailable commands:");
source.sendFailure(
"§e- /areacontrol status - Show current configuration",
);
source.sendFailure(
"§e- /areacontrol toggle - Enable/disable the system",
);
source.sendFailure(
"§e- /areacontrol setcenter - Set area center to current position",
);
source.sendFailure(
"§e- /areacontrol setradius <radius> - Set area radius",
);
source.sendFailure(
"§e- /areacontrol setmode <adventure|spectator> - Set area game mode",
);
source.sendFailure(
"§e- /areacontrol setcooldown <ticks> - Set item cooldown",
);
source.sendFailure(
"§e- /areacontrol whitelist <add|remove|list> [player] - Manage whitelist",
);
source.sendFailure(
"§e- /areacontrol reload - Reload configuration",
);
return 1;
};
// Register the main command with all subcommands
event.register(
commands
.literal("areacontrol")
.requires((source) => source.hasPermission(2))
.executes(statusCommand) // Default to status when no args
.then(commands.literal("status").executes(statusCommand))
.then(commands.literal("toggle").executes(toggleCommand))
.then(commands.literal("setcenter").executes(setCenterCommand))
.then(
commands
.literal("setradius")
.then(
commands
.argument(
"radius",
Arguments.INTEGER.create(event),
)
.executes(setRadiusCommand),
),
)
.then(
commands
.literal("setmode")
.then(
commands
.argument(
"mode",
Arguments.STRING.create(event),
)
.executes(setModeCommand),
),
)
.then(
commands
.literal("setcooldown")
.then(
commands
.argument(
"cooldown",
Arguments.INTEGER.create(event),
)
.executes(setCooldownCommand),
),
)
.then(
commands
.literal("whitelist")
.then(
commands
.literal("add")
.then(
commands
.argument(
"player",
Arguments.STRING.create(event),
)
.executes(whitelistAddCommand),
),
)
.then(
commands
.literal("remove")
.then(
commands
.argument(
"player",
Arguments.STRING.create(event),
)
.executes(whitelistRemoveCommand),
),
)
.then(
commands
.literal("list")
.executes(whitelistListCommand),
),
)
.then(commands.literal("reload").executes(reloadCommand))
.then(commands.literal("help").executes(helpCommand)),
);
});
}
// ==================== INITIALIZATION ====================
/**
* Initialize the AreaControl system
* @returns {void}
*/
function initializeAreaControl() {
console.log("[AreaControl] Initializing area control system...");
// Load configuration
loadConfiguration();
// Register event handlers
registerEventHandlers();
// Register commands
registerCommands();
console.log("[AreaControl] System initialized successfully");
console.log(
`[AreaControl] Area: center(${String(config.center.x)}, ${String(config.center.z)}), radius: ${String(config.radius)}`,
);
console.log(
`[AreaControl] Mode: ${config.mode}, Enabled: ${String(config.enabled)}`,
);
console.log(
`[AreaControl] Whitelisted players: ${String(config.whitelist.length)}`,
);
}
// ==================== STARTUP EXECUTION ====================
// Initialize the system when script loads
initializeAreaControl();