Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@ __pycache__/
/world/playerdata/

/pumpkin-ref/

# 本地编译产物 (由 build.bat/build.sh/CMake 生成)
native/terrain_gen
native/terrain_gen.exe
native/mob_ai
native/mob_ai.exe
native/pymc_native_server
native/pymc_native_server.exe
native/libpymc_native.so
native/pymc_native.dll
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型,
- [x] 玩家 JSON 存档:位置、生命值、饱食度、经验、游戏模式和个人出生点。
- [x] 基础聊天、方块挖掘/放置、掉落物、经验球和简单生物实体。
- [x] C++ 原生轻量生物 AI `native/mob_ai`,支持随机游走、看向玩家、敌对追击、近战冷却等行为。
- [x] 基础 `gamerule`:昼夜流动、自然刷怪、自然回血、死亡后自动重生回出生点。
- [x] 基础 `gamerule`:昼夜流动、天气循环 (doWeatherCycle)、自然刷怪、自然回血、死亡后自动重生回出生点。
- [x] **天气客户端同步**:通过 Game Event 数据包同步下雨/雷暴的开始、结束与强度;入服时同步当前天气,`/weather` 持续时间参数生效并立即广播。
- [x] **玩家互相可见**:Spawn Entity / Set Entity Data / Entity Teleport / Rotate Head 转发,玩家皮肤层元数据同步,移动更新按 `network-movement-rate-hz` 限频(原生 1.21.1 路径)。
- [x] **战斗系统**:攻击生物(武器伤害表)、生物死亡掉落与经验球、骷髅等远程生物标记。
- [x] **物品耐久与基础附魔**:工具/武器耐久消耗、耐久 (Unbreaking) 减免、锋利 (Sharpness) 增伤。
- [x] **雷暴闪电**:雷暴期间在随机玩家附近落雷,对附近实体造成伤害。
- [x] 控制台和游戏内基础命令。
- [x] Web 管理台、权限组、OP、封禁和白名单。
- [x] 单元测试覆盖原生地形、原生 AI、种子解析和安全出生点。
Expand All @@ -38,8 +43,8 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型,
- [ ] **流体系统**:已有基础水/岩浆传播与交互,但尚未达到完整原版规则。
- [ ] **多版本协议兼容**:已有 47-770 的版本映射和处理器框架;只有核心路径经过有限验证,不能宣称所有版本完整兼容。
- [ ] **命令覆盖**:`CommandManager`、权限、别名和大量命令已注册,但部分命令或子命令仍只返回“暂未实现”。
- [ ] **Watchdog**:已有 UDP 心跳、监控和重启框架,但缺少双进程端到端测试
- [ ] **网络优化**:已有批处理、限频和区块排序组件,但普通发送/移动路径尚未全面接入
- [ ] **Watchdog**:已有 UDP 心跳、监控、重启框架和真实 UDP 端到端测试,但尚未在长期运行环境中验证
- [ ] **网络优化**:批处理已接入实体同步广播路径、移动更新已限频,但普通发送路径尚未全面接入
- [x] **CI/CD**:GitHub Actions 工作流,Linux/macOS/Windows 三平台构建、CMake 原生组件编译、pytest 测试和 Nuitka 打包。

### 正在推进 / 待实现
Expand All @@ -49,9 +54,9 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型,
- [ ] 结构生成:村庄、废弃矿井、地牢、沉船、沙漠神殿、林地府邸、远古城市等。
- [ ] 完整洞穴系统:大型洞穴、繁茂洞穴、溶洞、地下水体、岩浆湖、深暗之域等。
- [ ] Nether、End 和多维度传送。
- [ ] 完整光照、天气效果、昼夜同步和客户端可见的区块更新细节
- [ ] 更完整的物品栏交互:拖拽、Shift-点击、合并、物品耐久、附魔、药水效果。
- [ ] 更完整的生物系统:寻路、繁殖、掉落、远程攻击、村民、Boss、刷怪规则和 mob cap。
- [ ] 完整光照和客户端可见的区块更新细节
- [ ] 更完整的物品栏交互:创造模式物品栏、铁砧/附魔台完整界面、药水效果。
- [ ] 更完整的生物系统:完整 A* 寻路、繁殖、村民、Boss、刷怪规则和 mob cap。
- [ ] 正版登录验证、加密链路和更完整的权限模型。
- [ ] 更完整的选择器、NBT 参数和 datapack/function 支持。

Expand All @@ -78,12 +83,16 @@ PyMC 是一个用 Python 实现的 Minecraft Java 版 1.21.1 服务端原型,
- Linear V2 `.linear` 区域文件读写,并支持从 Anvil `.mca` 自动转换
- 玩家位置、生命值、饱食度、经验、游戏模式等 JSON 存档
- 基础聊天、方块挖掘/放置、掉落物、经验球和简单生物实体
- 玩家互相可见:实体生成/元数据/移动转发(原生 1.21.1 路径)
- 战斗系统:攻击生物、武器伤害表、生物死亡掉落与经验、远程生物标记
- 物品耐久与基础附魔效果:耐久消耗、Unbreaking 减免、Sharpness 增伤
- 原版 Goal 思路的轻量生物 AI:随机游走、看向玩家、敌对追击、近战冷却
- 命令框架:大量命令注册、权限检查和别名;部分子命令仍未完成
- Mod/插件:仅支持 PYMC 原生 Python API
- Watchdog:UDP 心跳与自动重启框架(尚缺端到端验证)
- 网络优化:批处理、移动限频和区块排序组件(尚未全面接入)
- 基础 `gamerule`:控制昼夜流动、自然刷怪、自然回血和死亡自动重生
- Watchdog:UDP 心跳与自动重启框架(含真实 UDP 端到端测试)
- 网络优化:实体同步广播走批处理、移动更新限频、区块排序
- 基础 `gamerule`:控制昼夜流动、天气循环、自然刷怪、自然回血和死亡自动重生
- 天气客户端同步:Game Event 广播下雨/雷暴状态与强度,入服时同步当前天气;雷暴期间落雷并伤害附近实体
- 控制台和游戏内基础命令
- Web 管理台、权限组、OP、封禁和白名单

Expand Down
11 changes: 6 additions & 5 deletions commands/vanilla/enchant.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,12 @@ async def _execute(ctx: CommandContext) -> int:
# Apply enchantment (record on inventory item if available)
if hasattr(target, 'inventory_obj') and target.inventory_obj is not None:
slot = target.selected_hotbar_slot
item = target.inventory_obj.get_item_in_slot(slot)
if item is not None:
if "enchantments" not in item:
item["enchantments"] = {}
item["enchantments"][enchant_name] = level
item = target.inventory_obj.get_slot(slot)
if item is not None and not item.is_empty:
from world.item_properties import add_enchantment
add_enchantment(item, enchant_name, level)
from world.inventory import send_inventory_sync
await send_inventory_sync(target)
await ctx.reply(f"[PyMC] 已附魔 {target.username} 手持物品: {enchant_name} {level}")
return SUCCESS
else:
Expand Down
4 changes: 3 additions & 1 deletion commands/vanilla/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ async def _execute(ctx: CommandContext) -> int:
await ctx.reply(f"[PyMC] {e}")
return FAILURE

ctx.server.weather = weather
duration = 6000
if len(args) >= 2:
try:
duration = int(args[1]) * 20 # Convert seconds to ticks
except ValueError:
pass

# 走统一入口: 更新 TimeManager 持续时间并立即广播到客户端
await ctx.server.set_weather(weather, duration)

await ctx.reply(f"[PyMC] 天气已设置为 {weather}")
return SUCCESS

Expand Down
33 changes: 31 additions & 2 deletions handlers/play/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@
ENTITY_TYPE_IDS,
)

from handlers.play.combat import (
_handle_interact,
damage_mob,
get_attack_damage,
)

from handlers.play.spawn import (
_resolve_spawn_location,
_is_safe_player_location,
Expand All @@ -154,6 +160,29 @@
SPAWN_CANOPY_BLOCKS,
)

from handlers.play.weather import (
build_game_event_payload,
send_weather_state,
broadcast_weather_change,
rain_strength,
thunder_strength,
GAME_EVENT_END_RAINING,
GAME_EVENT_BEGIN_RAINING,
GAME_EVENT_RAIN_LEVEL,
GAME_EVENT_THUNDER_LEVEL,
)

from handlers.play.players import (
build_spawn_player_payload,
build_player_metadata_payload,
build_rotate_head_payload,
send_player_spawn,
sync_player_visibility,
relay_player_movement,
remove_player_entity,
PLAYER_ENTITY_TYPE,
)

logger = logging.getLogger("PyMC.游戏")


Expand Down Expand Up @@ -254,8 +283,8 @@ async def handle_play(conn: Connection, packet_id: int, payload: bytes,
_handle_close_container(conn, payload)

elif _is_serverbound_packet(conn, packet_id, "interact", 0x14):
# Interact (Entity)
pass # TODO: entity interaction
# Interact (Entity): 攻击动作由战斗系统处理
await _handle_interact(conn, payload, server)

elif _is_serverbound_packet(conn, packet_id, "set_creative_mode_slot", 0x22):
# Set Creative Mode Slot
Expand Down
5 changes: 5 additions & 0 deletions handlers/play/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,11 @@ async def _handle_block_dig(conn: Connection, payload: bytes, server):
return
await _broadcast_block_change(server, x, y, z, AIR)

# 生存/冒险模式下消耗工具耐久
if conn.gamemode in ("survival", "adventure"):
from world.item_properties import damage_held_item
await damage_held_item(conn, server)

# Notify redstone engine of block change
if server.redstone_engine:
server.redstone_engine.on_block_change(x, y, z, current, AIR)
Expand Down
148 changes: 148 additions & 0 deletions handlers/play/combat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# ============================================================
# PyMC - 战斗系统
# 玩家攻击生物、生物死亡掉落与经验
# ============================================================

"""
战斗处理。

包括:
- _handle_interact: 处理 Interact (serverbound 0x14) 攻击动作
- damage_mob: 对生物造成伤害, 死亡时生成掉落物和经验球
- WEAPON_DAMAGE: 手持武器伤害表

Interact 包格式 (1.21.1):
Entity ID (VarInt), Type (VarInt: 0=interact, 1=attack, 2=interact_at),
[type=2: target XYZ float x3], [type!=1: hand VarInt], sneaking (bool)
"""

import logging
import math
import random
import time

from protocol.data_types import read_varint
from network.connection import Connection

logger = logging.getLogger("PyMC.战斗")

INTERACT_TYPE_INTERACT = 0
INTERACT_TYPE_ATTACK = 1
INTERACT_TYPE_INTERACT_AT = 2

# 攻击判定距离 (平方), 比原版 3 格略宽松
ATTACK_RANGE_SQUARED = 16.0
# 攻击冷却 (秒), 防止客户端高频攻击包刷伤害
ATTACK_COOLDOWN_SECONDS = 0.4

# 手持武器伤害 (空手 = 1.0)
WEAPON_DAMAGE = {
"minecraft:wooden_sword": 4.0, "minecraft:golden_sword": 4.0,
"minecraft:stone_sword": 5.0, "minecraft:iron_sword": 6.0,
"minecraft:diamond_sword": 7.0, "minecraft:netherite_sword": 8.0,
"minecraft:wooden_axe": 7.0, "minecraft:golden_axe": 7.0,
"minecraft:stone_axe": 9.0, "minecraft:iron_axe": 9.0,
"minecraft:diamond_axe": 9.0, "minecraft:netherite_axe": 10.0,
"minecraft:trident": 9.0, "minecraft:mace": 6.0,
}

_rng = random.Random()


def parse_interact(payload: bytes) -> tuple[int, int] | None:
"""解析 Interact 包, 返回 (entity_id, action_type)。"""
try:
entity_id, offset = read_varint(payload, 0)
action_type, _ = read_varint(payload, offset)
return entity_id, action_type
except (IndexError, ValueError):
return None


def get_attack_damage(conn: Connection) -> float:
"""根据手持物品计算攻击伤害 (含锋利附魔加成)。"""
from world.item_properties import sharpness_damage_bonus
inventory = getattr(conn, "inventory_obj", None)
if inventory is not None:
held = inventory.get_held_item_from_slot(conn.selected_hotbar_slot)
if held is not None and not held.is_empty:
base = WEAPON_DAMAGE.get(held.item_id, 1.0)
return base + sharpness_damage_bonus(held)
return 1.0


async def _handle_interact(conn: Connection, payload: bytes, server):
"""处理 Interact 包中的攻击动作。"""
parsed = parse_interact(payload)
if parsed is None:
return
entity_id, action_type = parsed
if action_type != INTERACT_TYPE_ATTACK:
return

# 攻击冷却
now = time.monotonic()
last_attack = getattr(conn, "_last_attack_time", 0.0)
if now - last_attack < ATTACK_COOLDOWN_SECONDS:
return
conn._last_attack_time = now

entity = server.entity_manager.get_entity(entity_id)
if entity is None or entity.kind != "mob":
return
if entity.distance_squared_to(conn.x, conn.y, conn.z) > ATTACK_RANGE_SQUARED:
return

damage = get_attack_damage(conn)
killed = damage_mob(server, entity, damage, source=conn)

# 生存/冒险模式下消耗武器耐久
if conn.gamemode in ("survival", "adventure"):
from world.item_properties import damage_held_item
await damage_held_item(conn, server)

if killed:
from handlers.play.chat import send_system_message
mob_name = entity.metadata.get("mob_type", "生物")
await send_system_message(conn, f"[PyMC] 你击杀了 {mob_name}")


def damage_mob(server, entity, amount: float, source=None) -> bool:
"""
对生物造成伤害。死亡时生成掉落物和经验球并移除实体。

Returns:
True 如果生物被击杀。
"""
if entity.kind != "mob":
return False
entity.health -= float(amount)
entity.metadata["health"] = entity.health
if entity.health > 0:
return False

profile = getattr(entity, "profile", {})

# 掉落物
for drop in profile.get("drops", []):
item_name, min_count, max_count = drop
count = _rng.randint(min_count, max_count)
if count <= 0:
continue
item = server.entity_manager.create_item(
entity.x, entity.y + 0.3, entity.z, item_name, count)
item.pickup_delay = 10 # 0.5s 拾取延迟, 避免死亡瞬间被吸走

# 经验球
xp_range = profile.get("xp", (0, 0))
xp = _rng.randint(xp_range[0], xp_range[1]) if xp_range else 0
if xp > 0:
server.entity_manager.create_experience_orb(
entity.x, entity.y + 0.3, entity.z, xp)

server.entity_manager.remove_entity(entity.entity_id)
logger.info(
f"生物 {entity.metadata.get('mob_type', '?')} (id={entity.entity_id}) "
f"被击杀, 掉落已生成"
)
return True
26 changes: 17 additions & 9 deletions handlers/play/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@

logger = logging.getLogger("PyMC.实体")

# --- 实体类型 ID 映射 ---
# --- 实体类型 ID 映射 (1.21.1 注册表) ---
# 来源: PrismarineJS/minecraft-data pc/1.20.5 entities.json
# (dataPaths 将 1.21/1.21.1 映射到该注册表)
ENTITY_TYPE_IDS = {
"item": 71,
"cow": 30,
"pig": 100,
"sheep": 111,
"zombie": 150,
"item": 58,
"cow": 22,
"pig": 77,
"sheep": 87,
"zombie": 124,
"lightning_bolt": 64,
}


Expand Down Expand Up @@ -80,8 +83,8 @@ async def _send_generic_entity_spawn(conn: Connection, entity):
await conn.send_packet(0x01, bytes(payload))


async def _send_entity_teleport(conn: Connection, entity):
from protocol.packet_map import get_clientbound_packet
def build_entity_teleport_payload(entity) -> bytes:
"""构建 Entity Teleport 数据包负载。"""
payload = bytearray()
payload.extend(write_varint(entity.entity_id))
payload.extend(write_double(entity.x))
Expand All @@ -90,9 +93,14 @@ async def _send_entity_teleport(conn: Connection, entity):
payload.extend(write_angle(entity.yaw))
payload.extend(write_angle(entity.pitch))
payload.extend(write_boolean(entity.on_ground))
return bytes(payload)


async def _send_entity_teleport(conn: Connection, entity):
from protocol.packet_map import get_clientbound_packet
pid = get_clientbound_packet(conn.protocol_version, "entity_teleport")
if pid is not None:
await conn.send_packet(pid, bytes(payload))
await conn.send_packet(pid, build_entity_teleport_payload(entity))


def build_remove_entities(entity_ids: list[int]) -> bytes:
Expand Down
8 changes: 8 additions & 0 deletions handlers/play/join.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ def _generate_chunks(coords):
await _send_set_experience(conn)
await _send_time_update(conn, server)

# 同步当前天气状态 (晴天时为空操作)
from handlers.play.weather import send_weather_state
await send_weather_state(conn, server)

from world.inventory import send_inventory_sync
await send_inventory_sync(conn)

Expand Down Expand Up @@ -597,3 +601,7 @@ async def _broadcast_player_join(conn: Connection, server):
pid = get_clientbound_packet(conn.protocol_version, "player_info")
if pid is not None:
await conn.send_packet(pid, other_info)

# 双向生成玩家实体 (必须在 Player Info 之后, 客户端需要列表项渲染皮肤)
from handlers.play.players import sync_player_visibility
await sync_player_visibility(server, conn)
Loading