From 6c23eb4c62fe5f0f0612c1e67d1fce04613c03f9 Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sat, 19 Sep 2026 14:21:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E4=BA=9Bbu?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +- assets/config/blocks.json | 65 +- assets/config/items.json | 61 +- assets/config/recipes.json | 1 + assets/lang/zh_cn.json | 96 +- cmd/client/client_gl.go | 1853 ++++++++++++++++------ cmd/client/entities_gl.go | 128 +- cmd/client/input.go | 80 +- cmd/client/screens_gl.go | 123 +- cmd/server/main.go | 45 +- go.mod | 2 + go.sum | 4 + internal/atlas/atlas.go | 90 +- internal/atlas/atlas_test.go | 20 +- internal/audio/audio.go | 140 +- internal/audio/audio_test.go | 130 +- internal/block/registry.go | 142 +- internal/block/registry_test.go | 70 + internal/blockentity/blockentity.go | 50 +- internal/blockentity/blockentity_test.go | 15 + internal/entity/breeding.go | 101 +- internal/entity/breeding_test.go | 67 +- internal/entity/entity.go | 18 +- internal/entity/entity_test.go | 8 + internal/game/game.go | 802 +++++++++- internal/game/game_test.go | 573 ++++++- internal/inventory/inventory.go | 382 ++++- internal/inventory/inventory_test.go | 86 + internal/item/item.go | 29 + internal/logx/log.go | 86 +- internal/physics/player.go | 112 +- internal/physics/player_test.go | 44 + internal/recipe/recipe.go | 78 + internal/recipe/recipe_test.go | 28 + internal/render/animals_gl.go | 20 +- internal/render/mesh_gl.go | 8 + internal/render/overlay_gl.go | 187 +++ internal/render/renderer.go | 68 +- internal/save/region.go | 141 +- internal/save/save.go | 84 +- internal/save/save_test.go | 213 +++ internal/ui/crafting.go | 213 ++- internal/ui/hud.go | 169 +- internal/ui/inventory.go | 157 +- internal/ui/options.go | 42 +- internal/ui/screens.go | 180 ++- internal/ui/ui.go | 32 + internal/world/world.go | 36 + internal/world/world_test.go | 19 + resourcepacks/README.md | 21 - scripts/build.ps1 | 28 +- scripts/make_nsis.ps1 | 9 +- 文档/场景/UI与输入.md | 41 +- 文档/场景/UI还原.md | 69 +- 文档/场景/世界生态.md | 9 +- 文档/场景/动物体系.md | 6 + 文档/场景/区块管理.md | 4 +- 文档/场景/存档与持久化.md | 33 +- 文档/场景/实体系统.md | 9 +- 文档/场景/工作台与合成表.md | 19 +- 文档/场景/挖矿与矿物.md | 34 +- 文档/场景/方块交互与动画.md | 16 +- 文档/场景/村民系统.md | 2 + 文档/场景/渲染.md | 43 +- 文档/场景/物品与背包.md | 34 +- 文档/场景/物理与碰撞.md | 44 +- 文档/场景/红石与方块更新.md | 11 +- 文档/场景/音频.md | 55 +- 文档/存档格式.md | 15 +- 文档/构建与发布.md | 21 +- 文档/索引.md | 26 +- 文档/设计.md | 1782 ++++++++++----------- 文档/路线图.md | 115 +- 73 files changed, 7354 insertions(+), 2205 deletions(-) delete mode 100644 resourcepacks/README.md diff --git a/README.md b/README.md index cc0e2811..3626055b 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,21 @@ M3.5/M4/M6 进行中(见 [路线图.md](文档/路线图.md))。 正式发布用 **NSIS 3** 生成简体中文安装器,产品名统一为「年糕历险记」(见 [构建与发布.md](文档/构建与发布.md) §8)。 -**一键打包**(构建 + 生成安装器,需 `tools/` 下 zig 或 MinGW 提供 C 工具链;NSIS 3 自动查找 `tools/nsis/` 或 PATH): +**根目录一键脚本**(双击即可;需 `tools/` 下 zig 或 MinGW;安装包另需 `tools/nsis/`): -```powershell -pwsh -File scripts/make_nsis.ps1 -Version 1.0.0 +| 脚本 | 作用 | 产物 | +| --- | --- | --- | +| [`打包-dev.bat`](打包-dev.bat) | 开发构建(`gl` + `dev`) | `bin/mc-client.exe` | +| [`打包-安装包.bat`](打包-安装包.bat) | Release + NSIS 安装器 | `dist/年糕历险记-Setup-v<版本>.exe` | + +```bat +打包-dev.bat +打包-安装包.bat +打包-安装包.bat 1.0.0 ``` +等价 PowerShell:`scripts/build.ps1`、`scripts/make_nsis.ps1 -Version 1.0.0`。 + 产物:`dist/年糕历险记-Setup-v1.0.0.exe` **手动步骤**(不依赖一键脚本): diff --git a/assets/config/blocks.json b/assets/config/blocks.json index dd55ee66..28d2402e 100644 --- a/assets/config/blocks.json +++ b/assets/config/blocks.json @@ -1,35 +1,38 @@ { "blocks": { - "stone": { "id": 1, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/stone" }, "drops": "cobblestone" }, - "granite": { "id": 2, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/granite" } }, - "diorite": { "id": 3, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/diorite" } }, - "andesite": { "id": 4, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/andesite" } }, - "grass_block": { "id": 5, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/grass_block_top", "down": "block/dirt", "side": "block/grass_block_side" }, "tint": "grass", "drops": "dirt" }, - "dirt": { "id": 6, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/dirt" } }, - "cobblestone": { "id": 7, "hardness": 2.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/cobblestone" } }, - "oak_planks": { "id": 8, "hardness": 2.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/oak_planks" } }, - "bedrock": { "id": 9, "hardness": -1.0, "resistance": 3600000, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/bedrock" } }, - "sand": { "id": 10, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/sand" } }, - "gravel": { "id": 11, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/gravel" } }, - "oak_log": { "id": 12, "hardness": 2.0, "resistance": 2.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/oak_log_top", "down": "block/oak_log_top", "side": "block/oak_log" } }, - "oak_leaves": { "id": 13, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/oak_leaves" }, "cutout": true, "tint": "foliage", "no_drop": true }, - "glass": { "id": 14, "hardness": 0.3, "resistance": 1.5, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/glass" }, "cutout": true }, - "water": { "id": 15, "hardness": 100.0, "resistance": 500.0, "light": 0, "transparent": true, "solid": false, "textures": { "all": "block/water_still" }, "translucent": true, "fluid": true }, - "lava": { "id": 16, "hardness": 100.0, "resistance": 500.0, "light": 15, "transparent": true, "solid": false, "textures": { "all": "block/lava_still" }, "translucent": true, "fluid": true }, - "torch": { "id": 17, "hardness": 0.0, "resistance": 0.0, "light": 14, "transparent": true, "solid": false, "textures": { "all": "block/torch" }, "cutout": true }, - "crafting_table":{ "id": 18, "hardness": 2.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/crafting_table_top", "down": "block/oak_planks", "side": "block/crafting_table_side" } }, - "furnace": { "id": 19, "hardness": 3.5, "resistance": 3.5, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/furnace_top", "down": "block/furnace_top", "side": "block/furnace_side", "front": "block/furnace_front" } }, - "coal_ore": { "id": 20, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/coal_ore" }, "drops": "coal", "required_tier": 1 }, - "iron_ore": { "id": 21, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/iron_ore" }, "required_tier": 2 }, - "gold_ore": { "id": 22, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/gold_ore" }, "required_tier": 3 }, - "diamond_ore": { "id": 23, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/diamond_ore" }, "drops": "diamond", "required_tier": 3 }, - "redstone_ore": { "id": 24, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/redstone_ore" }, "drops": "redstone", "required_tier": 3 }, - "iron_block": { "id": 25, "hardness": 5.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/iron_block" } }, - "gold_block": { "id": 26, "hardness": 3.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/gold_block" } }, - "diamond_block": { "id": 27, "hardness": 5.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/diamond_block" } }, - "snow_block": { "id": 28, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/snow" } }, - "ice": { "id": 29, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/ice" }, "translucent": true }, - "obsidian": { "id": 30, "hardness": 50.0, "resistance": 1200.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/obsidian" }, "required_tier": 4 }, - "white_wool": { "id": 31, "hardness": 0.8, "resistance": 4.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/white_wool" } } + "stone": { "id": 1, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/stone" }, "drops": [{ "item": "cobblestone", "tool": "pickaxe" }] }, + "granite": { "id": 2, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/granite" }, "drops": [{ "item": "granite", "tool": "pickaxe" }] }, + "diorite": { "id": 3, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/diorite" }, "drops": [{ "item": "diorite", "tool": "pickaxe" }] }, + "andesite": { "id": 4, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/andesite" }, "drops": [{ "item": "andesite", "tool": "pickaxe" }] }, + "grass_block": { "id": 5, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "sound_group": "grass", "textures": { "up": "block/grass_block_top", "down": "block/dirt", "side": "block/grass_block_side" }, "tint": "grass", "drops": [{ "item": "dirt" }] }, + "dirt": { "id": 6, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "sound_group": "gravel", "textures": { "all": "block/dirt" }, "drops": [{ "item": "dirt" }] }, + "cobblestone": { "id": 7, "hardness": 2.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/cobblestone" }, "drops": [{ "item": "cobblestone", "tool": "pickaxe" }] }, + "oak_planks": { "id": 8, "hardness": 2.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "sound_group": "wood", "textures": { "all": "block/oak_planks" }, "drops": [{ "item": "oak_planks" }] }, + "bedrock": { "id": 9, "hardness": -1.0, "resistance": 3600000, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/bedrock" }, "no_drop": true }, + "sand": { "id": 10, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "sound_group": "sand", "textures": { "all": "block/sand" }, "drops": [{ "item": "sand" }] }, + "gravel": { "id": 11, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "sound_group": "gravel", "textures": { "all": "block/gravel" }, "drops": [{ "item": "flint", "chance": 0.1, "or_else": "gravel" }] }, + "oak_log": { "id": 12, "hardness": 2.0, "resistance": 2.0, "light": 0, "transparent": false, "solid": true, "sound_group": "wood", "textures": { "up": "block/oak_log_top", "down": "block/oak_log_top", "side": "block/oak_log" }, "drops": [{ "item": "oak_log" }] }, + "oak_leaves": { "id": 13, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": true, "solid": true, "sound_group": "grass", "textures": { "all": "block/oak_leaves" }, "cutout": true, "tint": "foliage", "drops": [{ "item": "oak_sapling", "chance": 0.05 }, { "item": "apple", "chance": 0.005 }] }, + "glass": { "id": 14, "hardness": 0.3, "resistance": 1.5, "light": 0, "transparent": true, "solid": true, "sound_group": "glass", "textures": { "all": "block/glass" }, "cutout": true, "no_drop": true }, + "water": { "id": 15, "hardness": 100.0, "resistance": 500.0, "light": 0, "transparent": true, "solid": false, "sound_group": "stone", "textures": { "all": "block/water_still" }, "translucent": true, "fluid": true, "no_drop": true }, + "lava": { "id": 16, "hardness": 100.0, "resistance": 500.0, "light": 15, "transparent": true, "solid": false, "sound_group": "stone", "textures": { "all": "block/lava_still" }, "translucent": true, "fluid": true, "no_drop": true }, + "torch": { "id": 17, "hardness": 0.0, "resistance": 0.0, "light": 14, "transparent": true, "solid": false, "sound_group": "wood", "textures": { "all": "block/torch" }, "cutout": true, "drops": [{ "item": "torch" }] }, + "crafting_table": { "id": 18, "hardness": 2.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "sound_group": "wood", "textures": { "up": "block/crafting_table_top", "down": "block/oak_planks", "side": "block/crafting_table_side" }, "drops": [{ "item": "crafting_table" }] }, + "furnace": { "id": 19, "hardness": 3.5, "resistance": 3.5, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "up": "block/furnace_top", "down": "block/furnace_top", "side": "block/furnace_side", "front": "block/furnace_front" }, "drops": [{ "item": "furnace", "tool": "pickaxe" }] }, + "coal_ore": { "id": 20, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/coal_ore" }, "required_tier": 1, "drops": [{ "item": "coal", "tool": "pickaxe" }] }, + "iron_ore": { "id": 21, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/iron_ore" }, "required_tier": 2, "drops": [{ "item": "raw_iron", "tool": "pickaxe", "required_tier": 2 }] }, + "gold_ore": { "id": 22, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/gold_ore" }, "required_tier": 3, "drops": [{ "item": "raw_gold", "tool": "pickaxe", "required_tier": 3 }] }, + "diamond_ore": { "id": 23, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/diamond_ore" }, "required_tier": 3, "drops": [{ "item": "diamond", "tool": "pickaxe", "required_tier": 3 }] }, + "redstone_ore": { "id": 24, "hardness": 3.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/redstone_ore" }, "required_tier": 3, "drops": [{ "item": "redstone", "min": 4, "max": 5, "tool": "pickaxe", "required_tier": 3 }] }, + "iron_block": { "id": 25, "hardness": 5.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/iron_block" }, "required_tier": 2, "drops": [{ "item": "iron_block", "tool": "pickaxe", "required_tier": 2 }] }, + "gold_block": { "id": 26, "hardness": 3.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/gold_block" }, "required_tier": 3, "drops": [{ "item": "gold_block", "tool": "pickaxe", "required_tier": 3 }] }, + "diamond_block": { "id": 27, "hardness": 5.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/diamond_block" }, "required_tier": 3, "drops": [{ "item": "diamond_block", "tool": "pickaxe", "required_tier": 3 }] }, + "snow_block": { "id": 28, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": false, "solid": true, "sound_group": "snow", "textures": { "all": "block/snow" }, "drops": [{ "item": "snowball", "min": 4, "max": 4 }] }, + "ice": { "id": 29, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": true, "solid": true, "sound_group": "glass", "textures": { "all": "block/ice" }, "translucent": true, "no_drop": true }, + "obsidian": { "id": 30, "hardness": 50.0, "resistance": 1200.0, "light": 0, "transparent": false, "solid": true, "sound_group": "stone", "textures": { "all": "block/obsidian" }, "required_tier": 4, "drops": [{ "item": "obsidian", "tool": "pickaxe", "required_tier": 4 }] }, + "white_wool": { "id": 31, "hardness": 0.8, "resistance": 4.0, "light": 0, "transparent": false, "solid": true, "sound_group": "wool", "textures": { "all": "block/white_wool" }, "drops": [{ "item": "white_wool" }] }, + "chest": { "id": 32, "hardness": 2.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "sound_group": "wood", "textures": { "up": "block/oak_planks", "down": "block/oak_planks", "side": "block/oak_planks", "front": "block/oak_planks" }, "drops": [{ "item": "chest" }] }, + "redstone_dust": { "id": 33, "hardness": 0.0, "resistance": 0.0, "light": 0, "transparent": true, "solid": false, "sound_group": "stone", "textures": { "all": "block/redstone_dust" }, "cutout": true, "drops": [{ "item": "redstone" }] }, + "lever": { "id": 34, "hardness": 0.5, "resistance": 0.5, "light": 0, "transparent": true, "solid": false, "sound_group": "wood", "textures": { "all": "block/lever" }, "cutout": true, "drops": [{ "item": "lever" }] } } } diff --git a/assets/config/items.json b/assets/config/items.json index 7f8b7f4f..3400c623 100644 --- a/assets/config/items.json +++ b/assets/config/items.json @@ -5,12 +5,42 @@ "diamond": { "stack": 64, "texture": "item/diamond" }, "iron_ingot": { "stack": 64, "texture": "item/iron_ingot" }, "gold_ingot": { "stack": 64, "texture": "item/gold_ingot" }, - "redstone": { "stack": 64, "texture": "item/redstone" }, + "redstone": { "stack": 64, "texture": "item/redstone", "place": "redstone_dust" }, + "lever": { "stack": 64, "texture": "block/lever", "place": "lever" }, + "water_bucket": { "stack": 1, "texture": "item/water_bucket", "place": "water" }, + "flint": { "stack": 64, "texture": "item/flint" }, + "oak_sapling": { "stack": 64, "texture": "block/oak_sapling" }, + "apple": { "stack": 64, "texture": "item/apple", "food": 4, "saturation": 2.4 }, + "snowball": { "stack": 16, "texture": "item/snowball" }, "oak_planks": { "stack": 64, "texture": "block/oak_planks", "place": "oak_planks" }, "cobblestone": { "stack": 64, "texture": "block/cobblestone", "place": "cobblestone" }, + "stone": { "stack": 64, "texture": "block/stone", "place": "stone" }, + "granite": { "stack": 64, "texture": "block/granite", "place": "granite" }, + "diorite": { "stack": 64, "texture": "block/diorite", "place": "diorite" }, + "andesite": { "stack": 64, "texture": "block/andesite", "place": "andesite" }, + "dirt": { "stack": 64, "texture": "block/dirt", "place": "dirt" }, + "grass_block": { "stack": 64, "texture": "block/grass_block_side", "place": "grass_block" }, + "sand": { "stack": 64, "texture": "block/sand", "place": "sand" }, + "gravel": { "stack": 64, "texture": "block/gravel", "place": "gravel" }, + "oak_log": { "stack": 64, "texture": "block/oak_log", "place": "oak_log" }, + "oak_leaves": { "stack": 64, "texture": "block/oak_leaves", "place": "oak_leaves" }, + "glass": { "stack": 64, "texture": "block/glass", "place": "glass" }, + "ice": { "stack": 64, "texture": "block/ice", "place": "ice" }, + "snow_block": { "stack": 64, "texture": "block/snow", "place": "snow_block" }, + "obsidian": { "stack": 64, "texture": "block/obsidian", "place": "obsidian" }, + "iron_ore": { "stack": 64, "texture": "block/iron_ore", "place": "iron_ore" }, + "gold_ore": { "stack": 64, "texture": "block/gold_ore", "place": "gold_ore" }, + "coal_ore": { "stack": 64, "texture": "block/coal_ore", "place": "coal_ore" }, + "diamond_ore": { "stack": 64, "texture": "block/diamond_ore", "place": "diamond_ore" }, + "redstone_ore": { "stack": 64, "texture": "block/redstone_ore", "place": "redstone_ore" }, + "iron_block": { "stack": 64, "texture": "block/iron_block", "place": "iron_block" }, + "gold_block": { "stack": 64, "texture": "block/gold_block", "place": "gold_block" }, + "diamond_block": { "stack": 64, "texture": "block/diamond_block", "place": "diamond_block" }, "crafting_table": { "stack": 64, "texture": "block/crafting_table_top", "place": "crafting_table" }, + "chest": { "stack": 64, "texture": "block/oak_planks", "place": "chest" }, "furnace": { "stack": 64, "texture": "block/furnace_front", "place": "furnace" }, "torch": { "stack": 64, "texture": "block/torch", "place": "torch" }, + "white_wool": { "stack": 64, "texture": "block/white_wool", "place": "white_wool" }, "wooden_pickaxe": { "stack": 1, "texture": "item/wooden_pickaxe", "tool": "pickaxe", "tier": 1, "speed": 2.0, "durability": 59 }, "stone_pickaxe": { "stack": 1, "texture": "item/stone_pickaxe", "tool": "pickaxe", "tier": 2, "speed": 4.0, "durability": 131 }, "iron_pickaxe": { "stack": 1, "texture": "item/iron_pickaxe", "tool": "pickaxe", "tier": 3, "speed": 6.0, "durability": 250 }, @@ -25,17 +55,32 @@ "diamond_axe": { "stack": 1, "texture": "item/diamond_axe", "tool": "axe", "tier": 4, "speed": 8.0, "durability": 1561 }, "raw_iron": { "stack": 64, "texture": "item/raw_iron" }, "raw_gold": { "stack": 64, "texture": "item/raw_gold" }, - "rotten_flesh": { "stack": 64, "texture": "item/rotten_flesh" }, - "beef": { "stack": 64, "texture": "item/beef" }, - "porkchop": { "stack": 64, "texture": "item/porkchop" }, - "chicken": { "stack": 64, "texture": "item/chicken" }, - "mutton": { "stack": 64, "texture": "item/mutton" }, + "rotten_flesh": { "stack": 64, "texture": "item/rotten_flesh", "food": 4, "saturation": 0.8 }, + "beef": { "stack": 64, "texture": "item/beef", "food": 3, "saturation": 1.8 }, + "porkchop": { "stack": 64, "texture": "item/porkchop", "food": 3, "saturation": 1.8 }, + "chicken": { "stack": 64, "texture": "item/chicken", "food": 2, "saturation": 1.2 }, + "mutton": { "stack": 64, "texture": "item/mutton", "food": 2, "saturation": 1.2 }, "leather": { "stack": 64, "texture": "item/leather" }, "feather": { "stack": 64, "texture": "item/feather" }, "egg": { "stack": 16, "texture": "item/egg" }, - "white_wool": { "stack": 64, "texture": "block/white_wool", "place": "white_wool" }, "wheat_seeds": { "stack": 64, "texture": "item/wheat_seeds" }, "wheat": { "stack": 64, "texture": "item/wheat" }, - "bone": { "stack": 64, "texture": "item/bone" } + "bone": { "stack": 64, "texture": "item/bone" }, + "leather_helmet": { "stack": 1, "texture": "item/leather_helmet", "equip": "helmet", "durability": 55 }, + "leather_chestplate": { "stack": 1, "texture": "item/leather_chestplate", "equip": "chestplate", "durability": 80 }, + "leather_leggings": { "stack": 1, "texture": "item/leather_leggings", "equip": "leggings", "durability": 75 }, + "leather_boots": { "stack": 1, "texture": "item/leather_boots", "equip": "boots", "durability": 65 }, + "iron_helmet": { "stack": 1, "texture": "item/iron_helmet", "equip": "helmet", "durability": 165 }, + "iron_chestplate": { "stack": 1, "texture": "item/iron_chestplate", "equip": "chestplate", "durability": 240 }, + "iron_leggings": { "stack": 1, "texture": "item/iron_leggings", "equip": "leggings", "durability": 225 }, + "iron_boots": { "stack": 1, "texture": "item/iron_boots", "equip": "boots", "durability": 195 }, + "diamond_helmet": { "stack": 1, "texture": "item/diamond_helmet", "equip": "helmet", "durability": 363 }, + "diamond_chestplate": { "stack": 1, "texture": "item/diamond_chestplate", "equip": "chestplate", "durability": 528 }, + "diamond_leggings": { "stack": 1, "texture": "item/diamond_leggings", "equip": "leggings", "durability": 495 }, + "diamond_boots": { "stack": 1, "texture": "item/diamond_boots", "equip": "boots", "durability": 429 }, + "shield": { "stack": 1, "texture": "item/shield", "equip": "offhand", "durability": 336 }, + "shears": { "stack": 1, "texture": "item/shears", "durability": 238 }, + "bucket": { "stack": 16, "texture": "item/bucket" }, + "milk_bucket": { "stack": 1, "texture": "item/milk_bucket" } } } diff --git a/assets/config/recipes.json b/assets/config/recipes.json index 96a07e7b..27fd9de3 100644 --- a/assets/config/recipes.json +++ b/assets/config/recipes.json @@ -5,6 +5,7 @@ { "type": "crafting_shaped", "pattern": ["XX", "XX"], "key": { "X": "oak_planks" }, "result": { "item": "crafting_table", "count": 1 } }, { "type": "crafting_shaped", "pattern": ["X", "Y"], "key": { "X": "coal", "Y": "stick" }, "result": { "item": "torch", "count": 4 } }, { "type": "crafting_shaped", "pattern": ["XXX", "X X", "XXX"], "key": { "X": "cobblestone" }, "result": { "item": "furnace", "count": 1 } }, + { "type": "crafting_shaped", "pattern": ["XXX", "X X", "XXX"], "key": { "X": "oak_planks" }, "result": { "item": "chest", "count": 1 } }, { "type": "crafting_shaped", "pattern": ["XXX", " Y ", " Y "], "key": { "X": "oak_planks", "Y": "stick" }, "result": { "item": "wooden_pickaxe", "count": 1 } }, { "type": "crafting_shaped", "pattern": ["XXX", " Y ", " Y "], "key": { "X": "cobblestone", "Y": "stick" }, "result": { "item": "stone_pickaxe", "count": 1 } }, { "type": "crafting_shaped", "pattern": ["XXX", " Y ", " Y "], "key": { "X": "iron_ingot", "Y": "stick" }, "result": { "item": "iron_pickaxe", "count": 1 } }, diff --git a/assets/lang/zh_cn.json b/assets/lang/zh_cn.json index 74d5d457..31f6a735 100644 --- a/assets/lang/zh_cn.json +++ b/assets/lang/zh_cn.json @@ -40,6 +40,8 @@ "item.minecraft.oak_planks": "橡木木板", "item.minecraft.cobblestone": "圆石", "item.minecraft.crafting_table": "工作台", + "item.minecraft.chest": "箱子", + "block.minecraft.chest": "箱子", "item.minecraft.furnace": "熔炉", "item.minecraft.torch": "火把", "item.minecraft.wooden_pickaxe": "木镐", @@ -68,6 +70,45 @@ "item.minecraft.wheat_seeds": "小麦种子", "item.minecraft.wheat": "小麦", "item.minecraft.bone": "骨头", + "item.minecraft.apple": "苹果", + "item.minecraft.snowball": "雪球", + "item.minecraft.flint": "燧石", + "item.minecraft.oak_sapling": "橡树树苗", + "item.minecraft.stone": "石头", + "item.minecraft.granite": "花岗岩", + "item.minecraft.diorite": "闪长岩", + "item.minecraft.andesite": "安山岩", + "item.minecraft.dirt": "泥土", + "item.minecraft.grass_block": "草方块", + "item.minecraft.sand": "沙子", + "item.minecraft.gravel": "沙砾", + "item.minecraft.oak_log": "橡木原木", + "item.minecraft.oak_leaves": "橡树树叶", + "item.minecraft.glass": "玻璃", + "item.minecraft.ice": "冰", + "item.minecraft.snow_block": "雪块", + "item.minecraft.obsidian": "黑曜石", + "item.minecraft.iron_ore": "铁矿石", + "item.minecraft.gold_ore": "金矿石", + "item.minecraft.coal_ore": "煤矿石", + "item.minecraft.diamond_ore": "钻石矿石", + "item.minecraft.redstone_ore": "红石矿石", + "item.minecraft.iron_block": "铁块", + "item.minecraft.gold_block": "金块", + "item.minecraft.diamond_block": "钻石块", + "item.minecraft.leather_helmet": "皮革帽子", + "item.minecraft.leather_chestplate": "皮革外套", + "item.minecraft.leather_leggings": "皮革裤子", + "item.minecraft.leather_boots": "皮革靴子", + "item.minecraft.iron_helmet": "铁头盔", + "item.minecraft.iron_chestplate": "铁胸甲", + "item.minecraft.iron_leggings": "铁护腿", + "item.minecraft.iron_boots": "铁靴子", + "item.minecraft.diamond_helmet": "钻石头盔", + "item.minecraft.diamond_chestplate": "钻石胸甲", + "item.minecraft.diamond_leggings": "钻石护腿", + "item.minecraft.diamond_boots": "钻石靴子", + "item.minecraft.shield": "盾牌", "menu.singleplayer": "单人游戏", "menu.splash": "牛气冲天!", @@ -86,6 +127,12 @@ "gui.language": "语言", "gui.resume": "继续游戏", "gui.save_and_quit": "保存并退出", + "menu.save": "保存存档", + "menu.saveAndQuit": "保存并退出", + "menu.quitWorld": "退出游戏", + "menu.saving": "正在保存…", + "menu.saved": "已保存", + "menu.loading": "正在加载…", "gui.game_paused": "游戏已暂停", "gui.hotbar": "快捷栏", "gui.recipe_book": "配方书", @@ -99,17 +146,58 @@ "options.renderDistance": "渲染距离", "options.fullscreen": "全屏", "selectWorld.title": "选择世界", + "selectWorld.select": "进入选中的世界", + "selectWorld.create": "创建新的世界", + "selectWorld.delete": "删除", + "selectWorld.deleteQuestion": "确定要删除这个世界吗?", + "selectWorld.deleteWarning": "将永远消失!(很长一段时间!)", + "selectWorld.empty": "还没有世界", + "selectWorld.enterSeed": "输入种子…", + "selectWorld.newWorld": "新的世界", + "gui.cancel": "取消", "createWorld.title": "创建新的世界", "createWorld.seed": "世界种子", "createWorld.worldName": "世界名称", "createWorld.gameMode": "游戏模式", - "selectWorld.create": "创建新的世界", - "selectWorld.enterSeed": "输入种子…", - "selectWorld.delete": "删除", + "createWorld.create": "创建新的世界", "world.gameMode.survival": "生存模式", "world.gameMode.creative": "创造模式", "deathScreen.title": "你死了!", "deathScreen.respawn": "重生", "deathScreen.titleScreen": "标题画面", - "chat.type.text": "发送消息" + "chat.type.text": "发送消息", + + "subtitles.entity.sheep.ambient": "羊:咩咩叫", + "subtitles.entity.sheep.hurt": "羊:受伤", + "subtitles.entity.sheep.death": "羊:死亡", + "subtitles.entity.cow.ambient": "牛:哞哞叫", + "subtitles.entity.cow.hurt": "牛:受伤", + "subtitles.entity.cow.death": "牛:死亡", + "subtitles.entity.pig.ambient": "猪:哼哼", + "subtitles.entity.pig.hurt": "猪:受伤", + "subtitles.entity.pig.death": "猪:死亡", + "subtitles.entity.chicken.ambient": "鸡:咯咯叫", + "subtitles.entity.chicken.hurt": "鸡:受伤", + "subtitles.entity.chicken.death": "鸡:死亡", + "subtitles.entity.zombie.ambient": "僵尸:呻吟", + "subtitles.entity.zombie.hurt": "僵尸:受伤", + "subtitles.entity.zombie.death": "僵尸:死亡", + "subtitles.entity.villager.ambient": "村民:喃喃自语", + "subtitles.entity.villager.hurt": "村民:受伤", + "subtitles.entity.villager.death": "村民:死亡", + "subtitles.entity.generic.hurt": "某物:受伤", + "subtitles.entity.player.hurt": "玩家:受伤", + "subtitles.entity.player.attack.strong": "玩家:用力攻击", + "subtitles.entity.player.attack.nodamage": "玩家:挥空", + "subtitles.entity.generic.big_fall": "某物:摔落", + "subtitles.entity.generic.small_fall": "某物:摔倒", + "subtitles.entity.player.big_fall": "玩家:摔落", + "subtitles.entity.player.small_fall": "玩家:摔倒", + "subtitles.block.generic.break": "方块:被破坏", + "subtitles.block.generic.footsteps": "脚步声", + "subtitles.block.generic.hit": "方块:敲击", + "subtitles.entity.generic.splash": "某物:溅起水花", + "subtitles.entity.generic.swim": "某物:游泳", + "subtitles.entity.generic.eat": "某物:进食", + "subtitles.entity.player.burp": "打嗝" } diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index 6e95eff9..a0d73390 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -2,12 +2,12 @@ // 年糕历险记 —— 客户端入口(GL 构建)。 // -// 装配顺序(架构.md §3.1 启动流程):配置 → 日志 → 内容包/图集 → 注册表 → 世界 -// → 游戏会话(玩家/背包/相机)→ 渲染器 → 主循环(tick 20Hz + render)。 +// 装配顺序:配置/日志 → 尽早 GLFW + Loading(screen=13)→ 图集/世界 → 主循环。 package main import ( "context" + "errors" "flag" "fmt" "image" @@ -17,21 +17,22 @@ import ( "os" "os/signal" "sort" + "strconv" + "strings" "sync" "sync/atomic" "time" "mc/internal/assets" - "mc/internal/atlas" "mc/internal/audio" "mc/internal/block" + "mc/internal/blockentity" "mc/internal/camera" "mc/internal/chunk" "mc/internal/config" "mc/internal/crash" "mc/internal/engine" "mc/internal/entity" - "mc/internal/font" "mc/internal/game" "mc/internal/inventory" "mc/internal/item" @@ -79,16 +80,40 @@ type clientApp struct { uiR *ui.Renderer hud *ui.HUD - // 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停 / 3 工作台 / 4 设置 / 5 物品栏(UI还原.md §2) - screen int - titleScreen *ui.TitleScreen - pauseScreen *ui.PauseScreen - craftScreen *ui.CraftingScreen - optionsScr *ui.OptionsScreen - invScreen *ui.InventoryScreen - optionsRet int // 设置界面的返回屏幕(0 主菜单 / 2 暂停) - held item.Stack // 合成界面手持栈(跟随鼠标) - audio *audio.Manager + // 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停 / 3 工作台 / 4 设置 / 5 物品栏 + // / 6 选择世界 / 7 创建世界 / 8 熔炉 / 9 箱子 / 10 创造物品栏 / 11 死亡 / 12 交易 / 13 Loading + screen int + titleScreen *ui.TitleScreen + pauseScreen *ui.PauseScreen + loadingScreen *ui.LoadingScreen + deathScreen *ui.DeathScreen + tradeScreen *ui.TradeScreen + craftScreen *ui.CraftingScreen + optionsScr *ui.OptionsScreen + invScreen *ui.InventoryScreen + furnaceScreen *ui.FurnaceScreen + chestScreen *ui.ChestScreen + creativeScreen *ui.CreativeScreen + selectWorld *ui.SelectWorldScreen + createWorld *ui.CreateWorldScreen + optionsRet int // 设置界面的返回屏幕(0 主菜单 / 2 暂停) + held item.Stack // 合成界面手持栈(跟随鼠标) + gameMode string // survival / creative + drag slotDrag + lastClickSlot string + lastClickAt time.Time + uiText string // book / creative + lastHotSel int + flies []pickupFly + armWarned bool + audio *audio.Manager + am *assets.Manager + audioLoaded bool + persistOnExit bool + saveHintUntil time.Time + saveHintTex uint32 + saveHintW float32 + saveHintH float32 // 设置值(设置界面滑条读写) sensVal float64 volVal float64 @@ -105,6 +130,17 @@ type clientApp struct { // 世界参数(client.yaml world.seed/name,存档与种子联动) worldSeed int64 worldName string + viewDist int + fov float64 + items *item.Registry + recipes *recipe.Registry + + clearChunks atomic.Bool + uiListDirty atomic.Bool + uiCreateDirty atomic.Bool + retireWorld *world.World + lastSlotAt time.Time + lastSlot int meshBuilder *mesh.Builder uv func(string) (u0, v0, u1, v1 float32, ok bool) @@ -137,12 +173,27 @@ type clientApp struct { targetImg image.Image targetDirty bool + captionSig string + captionImgs []image.Image + captionDirty bool + + // 槽位悬停 tooltip(tick 光栅化 CJK → 渲染线程上传) + tipName string + tipImg image.Image + tipDirty bool + // 小地图(tick 生成地表色图 → 渲染线程上传,UI还原.md §3.5) miniImg image.Image miniDirty bool blockColors []color.RGBA // 方块 ID → 图集中心色(启动时构建) + savePool *save.SavePool + pendingRemove [][2]int32 + winW, winH int + guiScale float32 + menuTime float64 + spawnY float64 tickCnt uint64 frames int // 渲染帧计数(截图时机用) lastTime time.Time @@ -171,6 +222,8 @@ func (a *clientApp) Tick(dt float64) { // 屏幕状态机(UI还原.md §2:主菜单/暂停不处理游戏输入) switch a.screen { case 0: // 主菜单 + a.in.textMode = false + a.ensureAudio() // dev 测试:-auto 自动进入游戏(跳过点击) if *autoEnter { a.screen = 1 @@ -179,6 +232,9 @@ func (a *clientApp) Tick(dt float64) { } if a.in.clicked { switch a.titleScreen.HitTest(a.in.clickX, a.in.clickY) { + case "select": + a.uiListDirty.Store(true) + a.screen = 6 case "play": a.screen = 1 a.requestCursor(true) @@ -200,11 +256,15 @@ func (a *clientApp) Tick(dt float64) { a.logf("已连接服务器") } case "quit": + if a.persistOnExit { + a.persistWorldWait() + } a.rend.Window().SetShouldClose(true) } a.in.clicked = false } a.in.dyaw, a.in.dpitch = 0, 0 + a.tickMenuBackdrop(dt) return case 2: // 暂停 if a.in.escPressed { @@ -219,8 +279,15 @@ func (a *clientApp) Tick(dt float64) { case "options": a.screen = 4 a.optionsRet = 2 - case "quit": - a.rend.Window().SetShouldClose(true) + case "save": + a.persistWorldWait() + a.saveHintUntil = time.Now().Add(2 * time.Second) + case "saveQuit": + a.persistWorldWait() + a.screen = 0 + a.requestCursor(false) + case "quitWorld": + a.discardWorldToTitle() } a.in.clicked = false } @@ -230,22 +297,28 @@ func (a *clientApp) Tick(dt float64) { // dev 测试:crafttest 第二阶段——合成输出格(tick 线程,确定性早于截图) if *craftTest && !a.craftClickShot { a.craftClickShot = true - a.handleCraftClick("result") + a.handleCraftClick("result", false, false) a.logf("crafttest 第二阶段: 合成输出格(2×2 木板 → 工作台)") } - if a.in.escPressed { + a.in.textMode = a.uiText == "book" + a.applySearchTyping(&a.craftScreen.BookQuery) + if a.craftScreen.BookOpen { + if d := a.consumeUIScroll(); d != 0 { + a.craftScreen.BookScroll += d + if a.craftScreen.BookScroll < 0 { + a.craftScreen.BookScroll = 0 + } + } + } else { + a.consumeUIScroll() + } + if a.tickSlotUI(dt, &a.craftScreen.OpenT, a.handleCraftClick) { a.closeCrafting() a.screen = 1 a.requestCursor(true) - a.in.escPressed = false return } - if a.in.clicked { - a.handleCraftClick(a.craftScreen.HitTest(a.in.clickX, a.in.clickY, float32(a.winW), float32(a.winH), 3)) - a.in.clicked = false - } - a.in.dyaw, a.in.dpitch = 0, 0 // 界面打开时不转动视角 - a.sess.Tick(dt) + a.updateSlotTooltip(a.craftHoverName()) return case 4: // 设置(灵敏度/音量滑条 + 按键教程,UI与输入.md §6) if a.in.escPressed { @@ -274,33 +347,175 @@ func (a *clientApp) Tick(dt float64) { } return case 5: // 物品栏(E 打开,UI还原.md §3.3:27 主区 + 热键栏,点击交换) - if a.in.escPressed || a.in.toggleInv { - a.in.escPressed = false - a.in.toggleInv = false - a.returnHeld() + a.consumeUIScroll() + if a.tickSlotUI(dt, &a.invScreen.OpenT, a.handleInvClick) { + a.closeInventory() a.screen = 1 a.requestCursor(true) return } + a.updateSlotTooltip(a.invHoverName()) + return + case 8: // 熔炉 + a.consumeUIScroll() + if a.tickSlotUI(dt, &a.furnaceScreen.OpenT, a.handleFurnaceClick) { + a.closeContainerUI() + a.screen = 1 + a.requestCursor(true) + return + } + a.updateSlotTooltip(a.furnaceHoverName()) + return + case 9: // 箱子 + a.consumeUIScroll() + if a.tickSlotUI(dt, &a.chestScreen.OpenT, a.handleChestClick) { + a.closeContainerUI() + a.screen = 1 + a.requestCursor(true) + return + } + a.updateSlotTooltip(a.chestHoverName()) + return + case 10: // 创造物品栏 + a.in.textMode = a.uiText == "creative" + a.applySearchTyping(&a.creativeScreen.Query) + if d := a.consumeUIScroll(); d != 0 { + a.creativeScreen.Scroll += d + if a.creativeScreen.Scroll < 0 { + a.creativeScreen.Scroll = 0 + } + } + if a.tickSlotUI(dt, &a.creativeScreen.OpenT, a.handleCreativeClick) { + a.returnHeld() + a.endDrag() + a.screen = 1 + a.requestCursor(true) + return + } + a.updateSlotTooltip(a.creativeHoverName()) + return + case 6: // 选择世界(UI还原.md §3.6) + a.in.textMode = false + if a.in.escPressed { + a.screen = 0 + a.selectWorld.Confirm = false + a.in.escPressed = false + a.tickMenuBackdrop(dt) + return + } if a.in.clicked { - a.handleInvClick(a.invScreen.HitTest(a.in.clickX, a.in.clickY, float32(a.winW), float32(a.winH), 3)) + a.handleSelectWorld(a.selectWorld.HitTest(a.in.clickX, a.in.clickY)) a.in.clicked = false } a.in.dyaw, a.in.dpitch = 0, 0 + a.tickMenuBackdrop(dt) + return + case 7: // 创建世界 + a.in.textMode = true + if a.in.escPressed { + a.in.textMode = false + a.screen = 6 + a.in.escPressed = false + a.tickMenuBackdrop(dt) + return + } + a.applyCreateTyping() + if a.in.clicked { + switch a.createWorld.HitTest(a.in.clickX, a.in.clickY) { + case "focus-name": + a.createWorld.Focus = 0 + a.uiCreateDirty.Store(true) + case "focus-seed": + a.createWorld.Focus = 1 + a.uiCreateDirty.Store(true) + case "mode": + if a.createWorld.GameMode == "creative" { + a.createWorld.GameMode = "survival" + } else { + a.createWorld.GameMode = "creative" + } + case "create": + a.in.textMode = false + a.finishCreateWorld() + case "cancel": + a.in.textMode = false + a.screen = 6 + } + a.in.clicked = false + } + a.in.dyaw, a.in.dpitch = 0, 0 + a.tickMenuBackdrop(dt) + return + case 11: // 死亡(实体系统.md §6:世界继续 Tick) + a.deathScreen.Cause = a.sess.DeathReason() + if a.in.clicked { + switch a.deathScreen.HitTest(a.in.clickX, a.in.clickY) { + case "respawn": + a.sess.Respawn() + a.screen = 1 + a.requestCursor(true) + case "title": + a.persistWorldWait() + a.screen = 0 + a.requestCursor(false) + } + a.in.clicked = false + } + a.in.escPressed = false a.sess.Tick(dt) + a.flushUnloaded() + return + case 12: // 村民交易 + if a.tradeScreen.OpenT < 1 { + a.tradeScreen.OpenT += float32(dt) * 8 + if a.tradeScreen.OpenT > 1 { + a.tradeScreen.OpenT = 1 + } + } + if a.in.escPressed { + a.sess.CloseTrade() + a.screen = 1 + a.requestCursor(true) + a.in.escPressed = false + return + } + if a.in.clicked { + slot := a.tradeScreen.HitTest(a.in.clickX, a.in.clickY, float32(a.winW), float32(a.winH), a.guiScale) + if strings.HasPrefix(slot, "offer") { + i, _ := strconv.Atoi(slot[5:]) + a.sess.ExecuteTrade(i) + } + a.in.clicked = false + } return } // 游戏中 + if a.sess.IsDead() { + a.deathScreen.Cause = a.sess.DeathReason() + a.screen = 11 + a.requestCursor(false) + a.sess.Tick(dt) + a.flushUnloaded() + return + } if a.in.escPressed { a.screen = 2 a.requestCursor(false) a.in.escPressed = false return } - // E 打开物品栏(UI还原.md §3.3) + // E 打开物品栏(创造模式为创造栏) if a.in.toggleInv { a.in.toggleInv = false - a.screen = 5 + if a.sess.Creative { + a.creativeScreen.OpenT = 0 + a.creativeScreen.Query = "" + a.uiText = "" + a.screen = 10 + } else { + a.invScreen.OpenT = 0 + a.screen = 5 + } a.requestCursor(false) return } @@ -320,14 +535,39 @@ func (a *clientApp) Tick(dt float64) { } a.in.apply(a.sess) // 水下检测(相机所在方块为水 → 水下渲染效果) - camB := a.sess.World.Block(int32(a.sess.Cam.Pos[0]), int32(a.sess.Cam.Pos[1]), int32(a.sess.Cam.Pos[2])) - a.underwater = a.sess.Reg.IsFluid(camB) || *waterTest - // 右键命中工作台 → 打开合成界面(apply 内 TryOpenCrafting 已置状态) - if a.sess.CraftingOpen() && a.screen == 1 { + a.underwater = a.sess.Player.EyeInWater || *waterTest + // 右键命中工作台 / 熔炉 / 箱子 + if a.screen == 1 && a.sess.CraftingOpen() { + a.sess.DiscoverCraftable() + a.craftScreen.OpenT = 0 a.screen = 3 a.requestCursor(false) a.logf("打开工作台合成界面") } + if a.screen == 1 && a.sess.ContainerOpen() == 1 { + a.furnaceScreen.OpenT = 0 + a.screen = 8 + a.requestCursor(false) + a.logf("打开熔炉") + } + if a.screen == 1 && a.sess.ContainerOpen() == 2 { + a.chestScreen.OpenT = 0 + a.screen = 9 + a.requestCursor(false) + a.logf("打开箱子") + } + if a.screen == 1 && a.sess.TradeOpen() { + a.tradeScreen.OpenT = 0 + a.screen = 12 + a.requestCursor(false) + a.logf("打开村民交易") + } + if a.sess.Inv.Selected != a.lastHotSel { + a.lastHotSel = a.sess.Inv.Selected + a.hud.Punch(a.lastHotSel) + } + a.hud.TickAnims(float32(dt)) + a.tickFlies(dt) // 手持方块 UV(选中槽变化时重算;渲染线程消费,渲染.md §5.2) if !a.handInit || a.sess.Inv.Selected != a.handSlot { a.computeHand() @@ -346,15 +586,17 @@ func (a *clientApp) Tick(dt float64) { } } a.sess.Tick(dt) + a.flushUnloaded() // 音频监听器跟随玩家(音频.md §3 距离衰减基准) if a.audio != nil { - a.audio.SetListener(a.sess.Player.Pos[0], a.sess.Player.Pos[1], a.sess.Player.Pos[2]) + a.audio.SetListener(a.sess.Player.Pos[0], a.sess.Player.Pos[1], a.sess.Player.Pos[2], a.sess.Cam.Yaw) } // 准星指向方块名(每 15 tick 更新;CJK 光栅化在 tick 线程,上传在渲染线程) if a.tickCnt%15 == 0 { a.updateTargetName() } + a.updateCaptions() // 小地图(每 30 tick 重建地表色图,UI还原.md §3.5) if a.tickCnt%30 == 0 { a.updateMinimap() @@ -373,16 +615,25 @@ func (a *clientApp) Tick(dt float64) { // 定时存档:30s(存档与持久化.md §2) a.tickCnt++ if a.tickCnt%(20*30) == 0 && a.net == nil { - if err := save.SaveWorld(a.sess.World, paths.Join("worlds"), a.worldName, a.worldSeed, int64(a.tickCnt)); err != nil { - // 日志由调用方输出(避免每次 tick 刷屏) - _ = err - } + a.persistWorld() } - // 脏区块网格重建(CPU 侧,预算内;按距玩家距离排序,先建视野中心—— - // 随机顺序会致出生区块迟迟无网格,玩家脚下的地面"透明",看到相邻区块地下横截面(黑)。 + a.rebuildDirtyMeshes(a.sess.Player.Pos[0], a.sess.Player.Pos[2]) +} + +func (a *clientApp) tickMenuBackdrop(dt float64) { + a.menuTime += dt + py := a.spawnY + if py < 8 { + py = 80 + } + a.sess.World.Update(8.5, py, 8.5, 8*time.Millisecond) + a.flushUnloaded() + a.rebuildDirtyMeshes(8.5, 8.5) +} + +func (a *clientApp) rebuildDirtyMeshes(px, pz float64) { start := time.Now() - px, pz := a.sess.Player.Pos[0], a.sess.Player.Pos[2] type dirtyJob struct { c *chunk.Chunk d float64 @@ -399,9 +650,8 @@ func (a *clientApp) Tick(dt float64) { sort.Slice(dirty, func(i, j int) bool { return dirty[i].d < dirty[j].d }) for _, dj := range dirty { if time.Since(start) > 6*time.Millisecond { - break // 帧预算(区块管理.md §4.3.2) + break } - // 整个构建持一次读锁:邻居查询免逐次加锁(渲染.md §3.3) a.sess.World.WithReadLock(func() { m := a.meshBuilder.Build(dj.c, func(x, y, z int32) block.State { return a.sess.World.BlockUnlocked(x, y, z) @@ -414,6 +664,494 @@ func (a *clientApp) Tick(dt float64) { } } +func (a *clientApp) persistWorldWait() { + a.persistWorld() + if a.savePool != nil { + a.savePool.Wait() + } +} + +func (a *clientApp) persistWorld() { + if a.sess == nil || a.net != nil || !a.persistOnExit { + return + } + pd := a.snapshotPlayer() + if pd == nil { + if old, err := save.ReadLevel(paths.Join("worlds"), a.worldName); err == nil && playerPosOK(old.Player) { + pd = old.Player + } + } + mode := a.gameMode + if mode == "" { + mode = "survival" + } + lv := save.LevelData{ + Version: 1, + Seed: a.worldSeed, + Time: int64(a.tickCnt), + GameMode: mode, + Spawn: a.sess.Spawn, + Player: pd, + BlockEntities: a.sess.ExportBlockEntities(), + } + regionDir := paths.Join("worlds", a.worldName, "region") + if err := save.EnqueueDirty(a.sess.World, a.savePool, regionDir); err != nil { + a.logf("存档失败: %v", err) + return + } + if lv.Version == 0 { + lv.Version = 1 + } + if err := save.WriteLevel(paths.Join("worlds"), a.worldName, lv); err != nil { + a.logf("存档失败: %v", err) + return + } + a.logf("已保存世界 %s", a.worldName) +} + +func (a *clientApp) flushUnloaded() { + if a.sess == nil { + return + } + regionDir := paths.Join("worlds", a.worldName, "region") + for _, c := range a.sess.World.DrainUnloaded() { + if c.SaveDirty.Load() && a.net == nil && a.savePool != nil { + a.savePool.Enqueue(save.ChunkJob{RegionDir: regionDir, CX: c.CX, CZ: c.CZ, Data: save.EncodeChunk(c)}) + c.SaveDirty.Store(false) + } + a.mu.Lock() + a.pendingRemove = append(a.pendingRemove, [2]int32{c.CX, c.CZ}) + a.mu.Unlock() + } +} + +func (a *clientApp) rasterize(s string, size float64, c color.RGBA) (uint32, float32, float32) { + cjk := loadCJK(size) + if cjk == nil || s == "" || a.uiR == nil { + return 0, 0, 0 + } + img := cjk.Draw(s, c) + return a.uiR.UploadImage(img), float32(img.Bounds().Dx()), float32(img.Bounds().Dy()) +} + +func (a *clientApp) drawSaveHint() { + if a.uiR == nil || time.Now().After(a.saveHintUntil) { + return + } + if a.saveHintTex == 0 { + label := "已保存" + if a.lang != nil { + if s := a.lang.Get("menu.saved"); s != "menu.saved" { + label = s + } + } + a.saveHintTex, a.saveHintW, a.saveHintH = a.rasterize(label, 16, color.RGBA{180, 255, 180, 255}) + } + if a.saveHintTex == 0 { + return + } + w, h := float32(a.winW), float32(a.winH) + x := w/2 - a.saveHintW/2 + y := h * 0.72 + a.uiR.DrawRect(x-8, y-4, a.saveHintW+16, a.saveHintH+8, [4]float32{0, 0, 0, 0.55}) + a.uiR.DrawImage(a.saveHintTex, x, y, a.saveHintW, a.saveHintH) +} + +func (a *clientApp) refreshWorldList() { + if a.selectWorld == nil { + return + } + list, err := save.ListWorlds(paths.Join("worlds")) + if err != nil { + a.logf("列出存档失败: %v", err) + list = nil + } + white := color.RGBA{255, 255, 255, 255} + gray := color.RGBA{200, 200, 200, 255} + rows := make([]ui.WorldRow, 0, len(list)) + for _, w := range list { + mode := w.GameMode + if a.lang != nil { + if s := a.lang.Get("world.gameMode." + mode); s != "world.gameMode."+mode { + mode = s + } + } + label, lw, lh := a.rasterize(w.Name, 16, white) + meta, mw, mh := a.rasterize(fmt.Sprintf("种子 %d %s", w.Seed, mode), 12, gray) + rows = append(rows, ui.WorldRow{ + Name: w.Name, Folder: w.Name, Seed: w.Seed, + LabelTex: label, LabelW: lw, LabelH: lh, + MetaTex: meta, MetaW: mw, MetaH: mh, + }) + } + a.selectWorld.Rows = rows + a.selectWorld.Confirm = false + if a.selectWorld.Selected >= len(rows) { + a.selectWorld.Selected = len(rows) - 1 + } + if a.selectWorld.Selected < 0 && len(rows) > 0 { + a.selectWorld.Selected = 0 + } +} + +func (a *clientApp) handleSelectWorld(act string) { + if strings.HasPrefix(act, "slot:") { + i, _ := strconv.Atoi(strings.TrimPrefix(act, "slot:")) + now := time.Now() + if i == a.selectWorld.Selected && i == a.lastSlot && now.Sub(a.lastSlotAt) < 400*time.Millisecond { + a.playSelectedWorld() + return + } + a.selectWorld.Selected = i + a.lastSlot, a.lastSlotAt = i, now + return + } + switch act { + case "play": + a.playSelectedWorld() + case "create": + a.openCreateWorld() + case "delete": + if a.selectWorld.Selected >= 0 && a.selectWorld.Selected < len(a.selectWorld.Rows) { + a.selectWorld.Confirm = true + } + case "cancel": + a.screen = 0 + case "confirm-yes": + a.deleteSelectedWorld() + case "confirm-no": + a.selectWorld.Confirm = false + } +} + +func (a *clientApp) playSelectedWorld() { + if a.selectWorld.Selected < 0 || a.selectWorld.Selected >= len(a.selectWorld.Rows) { + return + } + name := a.selectWorld.Rows[a.selectWorld.Selected].Folder + if name == a.worldName { + a.persistOnExit = true + a.screen = 1 + a.requestCursor(true) + return + } + a.persistWorld() + lv, err := save.ReadLevel(paths.Join("worlds"), name) + if err != nil { + a.logf("读存档失败: %v", err) + return + } + a.switchWorld(name, lv.Seed, true, lv.Player) +} + +func (a *clientApp) deleteSelectedWorld() { + if a.selectWorld.Selected < 0 || a.selectWorld.Selected >= len(a.selectWorld.Rows) { + return + } + name := a.selectWorld.Rows[a.selectWorld.Selected].Folder + if err := save.DeleteWorld(paths.Join("worlds"), name); err != nil { + a.logf("删除存档失败: %v", err) + a.selectWorld.Confirm = false + return + } + a.logf("已删除世界 %s", name) + if name == a.worldName { + fresh := save.UniqueWorldName(paths.Join("worlds"), "NewWorld") + a.switchWorld(fresh, time.Now().UnixNano(), false, nil) + a.screen = 6 + a.requestCursor(false) + } + a.uiListDirty.Store(true) +} + +func (a *clientApp) openCreateWorld() { + name := save.UniqueWorldName(paths.Join("worlds"), "新的世界") + a.createWorld.Name = name + a.createWorld.Seed = "" + a.createWorld.Focus = 0 + if a.createWorld.GameMode == "" { + a.createWorld.GameMode = "survival" + } + a.uiCreateDirty.Store(true) + a.screen = 7 +} + +func (a *clientApp) refreshCreateFields() { + white := color.RGBA{255, 255, 255, 255} + shown := a.createWorld.Name + if a.createWorld.Focus == 0 { + shown += "|" + } + a.createWorld.NameValTex, a.createWorld.NameValW, a.createWorld.NameValH = a.rasterize(shown, 16, white) + ss := a.createWorld.Seed + if ss == "" { + ss = "(空则随机)" + } + if a.createWorld.Focus == 1 { + ss += "|" + } + a.createWorld.SeedValTex, a.createWorld.SeedValW, a.createWorld.SeedValH = a.rasterize(ss, 16, white) +} + +func (a *clientApp) applyCreateTyping() { + changed := false + if a.in.backspace { + a.in.backspace = false + if a.createWorld.Focus == 0 && len(a.createWorld.Name) > 0 { + r := []rune(a.createWorld.Name) + a.createWorld.Name = string(r[:len(r)-1]) + changed = true + } else if a.createWorld.Focus == 1 && len(a.createWorld.Seed) > 0 { + r := []rune(a.createWorld.Seed) + a.createWorld.Seed = string(r[:len(r)-1]) + changed = true + } + } + if len(a.in.chars) > 0 { + for _, ch := range a.in.chars { + if ch < 32 { + continue + } + if a.createWorld.Focus == 0 && len([]rune(a.createWorld.Name)) < 32 { + a.createWorld.Name += string(ch) + changed = true + } else if a.createWorld.Focus == 1 && len([]rune(a.createWorld.Seed)) < 20 { + a.createWorld.Seed += string(ch) + changed = true + } + } + a.in.chars = a.in.chars[:0] + } + if changed { + a.uiCreateDirty.Store(true) + } +} + +func (a *clientApp) finishCreateWorld() { + dir := paths.Join("worlds") + name, err := save.SanitizeWorldName(a.createWorld.Name) + if err != nil { + name = save.UniqueWorldName(dir, "新的世界") + } else { + name = save.UniqueWorldName(dir, name) + } + seed := save.ParseWorldSeed(a.createWorld.Seed) + mode := a.createWorld.GameMode + if mode == "" { + mode = "survival" + } + a.persistWorld() + if err := save.WriteLevel(dir, name, save.LevelData{Version: 1, Seed: seed, GameMode: mode}); err != nil { + a.logf("创建存档失败: %v", err) + return + } + a.gameMode = mode + a.switchWorld(name, seed, false, nil) + if a.sess != nil { + a.sess.Creative = mode == "creative" + } +} + +func (a *clientApp) switchWorld(name string, seed int64, loadExisting bool, player *save.PlayerData) { + a.replaceWorld(name, seed, loadExisting, player) +} + +func (a *clientApp) replaceWorld(name string, seed int64, loadExisting bool, player *save.PlayerData) { + if a.reg == nil || a.items == nil || a.recipes == nil { + return + } + vd := a.viewDist + if vd < 2 { + vd = 8 + } + gen, err := worldgen.New(a.reg, seed, 0.005, 0.01, 0.1) + if err != nil { + a.logf("生成器失败: %v", err) + return + } + w, err := world.New(a.reg, gen, vd) + if err != nil { + a.logf("创建世界失败: %v", err) + return + } + if loadExisting { + if _, err := save.LoadWorld(w, paths.Join("worlds"), name); err != nil { + a.logf("载入世界失败: %v", err) + } + } + surf := waitSpawnSurface(w) + p := physics.NewPlayer(8.5, float64(surf+2), 8.5) + fov := a.fov + if fov == 0 { + fov = 70 + } + cam := camera.New(p.Eye(), 0, -0.35, fov) + inv := inventory.New(a.items) + sess := game.NewSession(w, a.reg, a.items, a.recipes, p, inv, cam) + old := a.sess.World + a.sess = sess + a.bindSessionHooks() + if a.hud != nil { + a.hud.SetSession(sess) + } + a.meshBuilder = newMeshBuilder(a.reg, a.uv, sess) + a.mu.Lock() + a.pendingMesh = nil + a.retireWorld = old + a.mu.Unlock() + a.clearChunks.Store(true) + a.worldName, a.worldSeed = name, seed + a.spawnY = float64(surf) + if loadExisting { + if lv, err := save.ReadLevel(paths.Join("worlds"), name); err == nil { + a.sess.ImportBlockEntities(lv.BlockEntities) + a.gameMode = lv.GameMode + a.sess.Creative = lv.GameMode == "creative" + if lv.Spawn[1] != 0 { + a.sess.Spawn = lv.Spawn + } + if player == nil { + player = lv.Player + } + } + } else if a.gameMode == "" { + a.gameMode = "survival" + } else { + a.sess.Creative = a.gameMode == "creative" + } + if player != nil { + a.restorePlayer(player) + } + a.lastHotSel = a.sess.Inv.Selected + a.persistOnExit = true + a.ensureAudio() + a.screen = 1 + a.requestCursor(true) + a.logf("进入世界 %s 种子 %d", name, seed) +} + +func (a *clientApp) discardWorldToTitle() { + if a.sess == nil { + a.screen = 0 + a.requestCursor(false) + return + } + name, seed := a.worldName, a.worldSeed + var player *save.PlayerData + if lv, err := save.ReadLevel(paths.Join("worlds"), name); err == nil { + player = lv.Player + seed = lv.Seed + } + a.persistOnExit = false + a.replaceWorld(name, seed, true, player) + a.persistOnExit = false + a.screen = 0 + a.requestCursor(false) + a.logf("已丢弃未保存改动,返回标题") +} + +func (a *clientApp) ensureAudio() { + if a.audioLoaded || a.audio == nil || a.am == nil { + return + } + a.audioLoaded = true + if jsPath, ok := a.am.ResolveFile("", "sounds", ".json"); ok { + if raw, err := os.ReadFile(jsPath); err == nil { + a.audio.LoadSounds(raw, func(name string) (string, bool) { + return a.am.ResolveFile("sounds", name, ".ogg") + }) + a.logf("已加载音效事件 %d 个", a.audio.EventCount()) + } + } +} + +func waitSpawnSurface(w *world.World) int32 { + var surf int32 = 120 + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + w.Update(8.5, 100, 8.5, 8*time.Millisecond) + if w.Block(8, 80, 8) != block.Air { + break + } + time.Sleep(2 * time.Millisecond) + } + for y := int32(255); y > 0; y-- { + if w.Block(8, y, 8) != block.Air { + return y + } + } + return surf +} + +func playerPosOK(pd *save.PlayerData) bool { + return pd != nil && pd.Y >= 1 +} + +func (a *clientApp) snapshotPlayer() *save.PlayerData { + p := a.sess.Player + if p.Pos[1] < 1 { + return nil + } + pd := &save.PlayerData{ + X: p.Pos[0], Y: p.Pos[1], Z: p.Pos[2], + Yaw: a.sess.Cam.Yaw, Pitch: a.sess.Cam.Pitch, + Health: float64(a.sess.Health), Hunger: a.sess.Hunger, + Saturation: a.sess.Saturation, + Selected: a.sess.Inv.Selected, + } + pd.Slots = make([]save.InvSlot, len(a.sess.Inv.Slots)) + for i, st := range a.sess.Inv.Slots { + pd.Slots[i] = save.InvSlot{Name: st.Name, Count: st.Count, Durability: st.Durability} + } + pd.UnlockedRecipes = a.sess.ExportUnlocked() + return pd +} + +func (a *clientApp) restorePlayer(pd *save.PlayerData) { + if pd == nil { + return + } + if playerPosOK(pd) { + a.sess.Player.Pos = [3]float64{pd.X, pd.Y, pd.Z} + a.sess.Cam.Pos = a.sess.Player.Eye() + a.sess.Cam.Yaw, a.sess.Cam.Pitch = pd.Yaw, pd.Pitch + } else { + a.logf("忽略无效玩家坐标 (%.1f, %.1f, %.1f)", pd.X, pd.Y, pd.Z) + } + if pd.Health > 0 { + a.sess.Health = int(pd.Health) + } + if pd.Hunger > 0 { + a.sess.Hunger = pd.Hunger + } + a.sess.Saturation = pd.Saturation + if pd.Selected >= 0 && pd.Selected < 9 { + a.sess.Inv.Selected = pd.Selected + } + for i := 0; i < len(pd.Slots) && i < len(a.sess.Inv.Slots); i++ { + s := pd.Slots[i] + a.sess.Inv.Slots[i] = item.Stack{Name: s.Name, Count: s.Count, Durability: s.Durability} + } + a.sess.ImportUnlocked(pd.UnlockedRecipes) +} + +func (a *clientApp) menuOrbitCam() *camera.Camera { + sy := a.spawnY + if sy < 8 { + sy = 64 + } + ang := a.menuTime * 0.12 + tx, tz := 8.5, 8.5 + ty := sy + 2 + cx := tx + math.Sin(ang)*22 + cz := tz + math.Cos(ang)*22 + cy := sy + 10 + dx, dy, dz := tx-cx, ty-cy, tz-cz + yaw := math.Atan2(-dx, -dz) + pitch := math.Atan2(dy, math.Sqrt(dx*dx+dz*dz)) + return camera.New([3]float64{cx, cy, cz}, yaw, pitch, 70) +} + // injectDevVisuals dev 测试注入(渲染线程,3D 绘制之前调用): // -crackstage 设置破坏裂纹阶段(视线前方方块);-spawntest 生成实体; // -esctest 注入 ESC(下一 tick 转暂停并请求释放光标)。 @@ -494,23 +1232,119 @@ func (a *clientApp) injectDevVisuals() { } } -// handleCraftClick 工作台界面点击:网格/热键栏槽与手持栈交换,输出格执行合成。 -func (a *clientApp) handleCraftClick(slot string) { +// handleCraftClick 工作台点击:左/右/Shift(工作台与合成表.md §5、UI与输入.md §4)。 +func (a *clientApp) handleCraftClick(slot string, right, shift bool) { + switch { + case slot == "": + return + case slot == "book": + a.craftScreen.BookOpen = !a.craftScreen.BookOpen + if !a.craftScreen.BookOpen { + a.uiText = "" + } + case slot == "search": + a.uiText = "book" + case strings.HasPrefix(slot, "recipe"): + var i int + fmt.Sscanf(slot[6:], "%d", &i) + list := ui.FilterRecipes(a.sess.Recipes.CraftingRecipes(), a.craftScreen.BookQuery, a.craftScreen.Labels) + if i >= 0 && i < len(list) && a.sess.RecipeUnlocked(list[i].Result.Item) { + a.sess.CraftingFillRecipe(list[i]) + } + case slot == "result": + if shift { + a.sess.CraftingTakeResultAll() + return + } + a.takeCraftResult(right) + case strings.HasPrefix(slot, "grid"): + i := int(slot[len(slot)-1] - '0') + if shift && !right { + a.sess.CraftingShiftGrid(i) + return + } + a.held = a.sess.CraftingClick(i, a.held, right) + case strings.HasPrefix(slot, "main"), strings.HasPrefix(slot, "hot"): + idx := a.invIndex(slot) + if idx < 0 { + return + } + if shift && !right { + a.sess.CraftingShiftInv(idx) + return + } + a.held = a.sess.Inv.ClickSlot(idx, a.held, right) + } +} + +func (a *clientApp) takeCraftResult(right bool) { + res, ok := a.sess.CraftingResult() + if !ok { + return + } + if a.held.Empty() { + if r, ok := a.sess.CraftingTakeResult(); ok { + a.held = r + } + return + } + if a.held.Name != res.Name || a.held.Count >= a.held.Limit(a.sess.Items) { + return + } + if r, ok := a.sess.CraftingTakeResult(); ok { + merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), a.held, r) + a.held = merged + if !remain.Empty() { + if rem := a.sess.Inv.Add(remain); !rem.Empty() { + a.sess.Entities.SpawnDrop(rem.Name, int(rem.Count), a.sess.Player.Pos[0], a.sess.Player.Pos[1]+1, a.sess.Player.Pos[2]) + } + } + } + _ = right +} + +func (a *clientApp) invIndex(slot string) int { + var idx int + switch { + case strings.HasPrefix(slot, "main"): + fmt.Sscanf(slot[4:], "%d", &idx) + return idx + 9 + case strings.HasPrefix(slot, "hot"): + fmt.Sscanf(slot[3:], "%d", &idx) + if idx >= 0 && idx < inventory.HotbarSize { + return idx + } + case strings.HasPrefix(slot, "armor"): + fmt.Sscanf(slot[5:], "%d", &idx) + if idx >= 0 && idx < 4 { + return inventory.SlotHelmet + idx + } + case slot == "offhand": + return inventory.SlotOffhand + } + return -1 +} + +// handleInvClick 物品栏点击:存储/盔甲/副手/2×2/输出(UI还原.md §3.3)。 +func (a *clientApp) handleInvClick(slot string, right, shift bool) { switch { case slot == "": return case slot == "result": - // 输出格:空手持取走产物;同名手持合并产物继续合成(工作台与合成表.md §5) - res, ok := a.sess.CraftingResult() + if shift { + a.sess.InvCraftTakeResultAll() + return + } + res, ok := a.sess.InvCraftResult() if !ok { return } if a.held.Empty() { - if r, ok := a.sess.CraftingTakeResult(); ok { + if r, ok := a.sess.InvCraftTakeResult(); ok { a.held = r } } else if a.held.Name == res.Name && a.held.Count < a.held.Limit(a.sess.Items) { - if r, ok := a.sess.CraftingTakeResult(); ok { + if r, ok := a.sess.InvCraftTakeResult(); ok { merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), a.held, r) a.held = merged if !remain.Empty() { @@ -520,62 +1354,24 @@ func (a *clientApp) handleCraftClick(slot string) { } } } - case len(slot) == 5 && slot[:4] == "grid": - i := int(slot[4] - '0') - a.held = a.sess.CraftingSwap(i, a.held) - case len(slot) == 4 && slot[:3] == "hot": - i := int(slot[3] - '0') - if i >= 0 && i < inventory.HotbarSize { - cur := a.sess.Inv.Slots[i] - switch { - case a.held.Empty(): - a.sess.Inv.Slots[i] = item.Stack{} - a.held = cur - case cur.Empty(): - a.sess.Inv.Slots[i] = a.held - a.held = item.Stack{} - case cur.Name == a.held.Name: - merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), cur, a.held) - a.sess.Inv.Slots[i] = merged - a.held = remain - default: - a.sess.Inv.Slots[i] = a.held - a.held = cur - } + case strings.HasPrefix(slot, "craft"): + var i int + fmt.Sscanf(slot[5:], "%d", &i) + if shift && !right { + a.sess.InvCraftShift(i) + return } - } -} - -// handleInvClick 物品栏界面点击:主区/热键栏槽与手持栈交换/合并。 -func (a *clientApp) handleInvClick(slot string) { - var idx int - switch { - case len(slot) == 5 && slot[:4] == "main": - fmt.Sscanf(slot[4:], "%d", &idx) - idx += 9 // 主物品区 = 槽 9..35 - case len(slot) == 4 && slot[:3] == "hot": - fmt.Sscanf(slot[3:], "%d", &idx) + a.held = a.sess.InvCraftClick(i, a.held, right) default: - return - } - if idx < 0 || idx >= inventory.TotalSize { - return - } - cur := a.sess.Inv.Slots[idx] - switch { - case a.held.Empty(): - a.sess.Inv.Slots[idx] = item.Stack{} - a.held = cur - case cur.Empty(): - a.sess.Inv.Slots[idx] = a.held - a.held = item.Stack{} - case cur.Name == a.held.Name: - merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), cur, a.held) - a.sess.Inv.Slots[idx] = merged - a.held = remain - default: - a.sess.Inv.Slots[idx] = a.held - a.held = cur + idx := a.invIndex(slot) + if idx < 0 { + return + } + if shift && !right { + a.sess.Inv.ShiftFromInv(idx) + return + } + a.held = a.sess.Inv.ClickSlot(idx, a.held, right) } } @@ -594,6 +1390,94 @@ func (a *clientApp) returnHeld() { func (a *clientApp) closeCrafting() { a.sess.CloseCrafting() a.returnHeld() + a.endDrag() + a.uiText = "" + a.in.textMode = false + if a.craftScreen != nil { + a.craftScreen.BookOpen = false + } +} + +func (a *clientApp) bindSessionHooks() { + if a.sess == nil { + return + } + if a.audio != nil { + a.sess.Sound = func(key string, x, y, z float64) { a.audio.Play(key, x, y, z) } + } + a.sess.Pickup = func(name string, x, y, z float64, slot int) { + a.mu.Lock() + a.flies = append(a.flies, pickupFly{name: name, wx: x, wy: y, wz: z, slot: slot}) + a.mu.Unlock() + } + if tbl, err := entity.LoadTrades(paths.Join("assets", "config", "villager_trades.json")); err == nil { + a.sess.Trades = tbl + } +} + +func (a *clientApp) tickFlies(dt float64) { + a.mu.Lock() + n := 0 + for _, f := range a.flies { + f.age += float32(dt) + if f.age < 0.4 { + a.flies[n] = f + n++ + } else if a.hud != nil { + a.hud.Punch(f.slot) + } + } + a.flies = a.flies[:n] + a.mu.Unlock() +} + +func (a *clientApp) drawPickupFlies() { + a.mu.Lock() + flies := append([]pickupFly(nil), a.flies...) + a.mu.Unlock() + if len(flies) == 0 { + return + } + aspect := float64(a.winW) / float64(a.winH) + view, proj := a.sess.Cam.View(), a.sess.Cam.Projection(aspect, 0.05, 1024) + w, h := float32(a.winW), float32(a.winH) + for _, f := range flies { + t := f.age / 0.4 + if t > 1 { + t = 1 + } + sx, sy, ok := projectWorld(view, proj, f.wx, f.wy, f.wz, w, h) + dx, dy := a.hud.HotbarIconPos(f.slot, w, h) + x := sx + (dx-sx)*t + y := sy + (dy-sy)*t + if !ok { + x, y = dx, dy + } + size := (1.2 - 0.6*t) * a.guiScale + a.hud.DrawIconAt(a.uiR, f.name, x, y, 16*size) + } +} + +func projectWorld(view, proj [16]float32, x, y, z float64, w, h float32) (sx, sy float32, ok bool) { + fx, fy, fz := float32(x), float32(y), float32(z) + vx := view[0]*fx + view[4]*fy + view[8]*fz + view[12] + vy := view[1]*fx + view[5]*fy + view[9]*fz + view[13] + vz := view[2]*fx + view[6]*fy + view[10]*fz + view[14] + vw := view[3]*fx + view[7]*fy + view[11]*fz + view[15] + cx := proj[0]*vx + proj[4]*vy + proj[8]*vz + proj[12]*vw + cy := proj[1]*vx + proj[5]*vy + proj[9]*vz + proj[13]*vw + cw := proj[3]*vx + proj[7]*vy + proj[11]*vz + proj[15]*vw + if cw <= 0.01 { + return 0, 0, false + } + ndcX, ndcY := cx/cw, cy/cw + return (ndcX*0.5 + 0.5) * w, (0.5 - ndcY*0.5) * h, true +} + +// closeInventory 关闭物品栏:2×2 余料与手持栈退回背包。 +func (a *clientApp) closeInventory() { + a.sess.CloseInvCraft() + a.returnHeld() } // updateTargetName 计算准星指向方块的显示名(lang 本地化 → CJK 光栅化,UI还原.md §3.4)。 @@ -626,6 +1510,122 @@ func (a *clientApp) updateTargetName() { a.targetDirty = true } +func (a *clientApp) updateCaptions() { + if a.audio == nil { + return + } + caps := a.audio.Captions() + labels := make([]string, 0, len(caps)) + for _, c := range caps { + lab := c.Subtitle + if lab == "" { + lab = c.Event + } + if a.lang != nil { + if s := a.lang.Get(lab); s != lab { + lab = s + } + } + labels = append(labels, lab) + } + sig := strings.Join(labels, "\n") + if sig == a.captionSig { + return + } + a.captionSig = sig + a.mu.Lock() + defer a.mu.Unlock() + a.captionImgs = nil + if r := loadCJK(12); r != nil { + for _, lab := range labels { + a.captionImgs = append(a.captionImgs, r.Draw(lab, color.RGBA{230, 230, 230, 255})) + } + } + a.captionDirty = true +} + +func (a *clientApp) itemDisplayName(name string) string { + if name == "" { + return "" + } + if a.lang != nil { + if s := a.lang.Get("item.minecraft." + name); s != "item.minecraft."+name { + return s + } + if s := a.lang.Get("block.minecraft." + name); s != "block.minecraft."+name { + return s + } + } + return name +} + +func (a *clientApp) stackAtInvHit(slot string) item.Stack { + switch { + case slot == "result": + st, _ := a.sess.InvCraftResult() + return st + case strings.HasPrefix(slot, "craft"): + var i int + fmt.Sscanf(slot[5:], "%d", &i) + return a.sess.InvCraftSlot(i) + default: + idx := a.invIndex(slot) + if idx >= 0 && idx < len(a.sess.Inv.Slots) { + return a.sess.Inv.Slots[idx] + } + } + return item.Stack{} +} + +func (a *clientApp) invHoverName() string { + slot := a.invScreen.HitTest(float32(a.in.cursorX), float32(a.in.cursorY), float32(a.winW), float32(a.winH), a.guiScale) + return a.stackAtInvHit(slot).Name +} + +func (a *clientApp) craftHoverName() string { + slot := a.craftScreen.HitTest(float32(a.in.cursorX), float32(a.in.cursorY), float32(a.winW), float32(a.winH), a.guiScale) + switch { + case slot == "result": + st, _ := a.sess.CraftingResult() + return st.Name + case strings.HasPrefix(slot, "grid"): + i := int(slot[len(slot)-1] - '0') + return a.sess.CraftSlot(i).Name + case strings.HasPrefix(slot, "recipe"): + var i int + fmt.Sscanf(slot[6:], "%d", &i) + list := ui.FilterRecipes(a.sess.Recipes.CraftingRecipes(), a.craftScreen.BookQuery, a.craftScreen.Labels) + if i >= 0 && i < len(list) { + return list[i].Result.Item + } + default: + idx := a.invIndex(slot) + if idx >= 0 && idx < len(a.sess.Inv.Slots) { + return a.sess.Inv.Slots[idx].Name + } + } + return "" +} + +func (a *clientApp) updateSlotTooltip(name string) { + if name == a.tipName { + return + } + a.tipName = name + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.tipImg = nil + a.tipDirty = true + return + } + label := a.itemDisplayName(name) + if r := loadCJK(12); r != nil { + a.tipImg = r.Draw(label, color.RGBA{255, 255, 255, 255}) + } + a.tipDirty = true +} + // leaveOptions 离开设置界面:返回来源屏幕(暂停 → 重新捕获光标)。 func (a *clientApp) leaveOptions() { a.screen = a.optionsRet @@ -745,12 +1745,12 @@ func (a *clientApp) computeHand() { a.handSlot = a.sess.Inv.Selected sel := a.sess.Inv.SelectedStack() if sel.Empty() { - a.computeFistHand() + a.clearHeldItem() return } d, ok := a.sess.Items.Get(sel.Name) if !ok { - a.computeFistHand() + a.clearHeldItem() return } if d.Place != "" { @@ -767,14 +1767,14 @@ func (a *clientApp) computeHand() { a.mu.Unlock() return } - a.computeFistHand() + a.clearHeldItem() } // computeBlockHand 手持可放置方块:六面贴图立方体(渲染.md §5.2)。 func (a *clientApp) computeBlockHand(place string) { bid, ok := a.reg.ID(place) if !ok { - a.computeFistHand() + a.clearHeldItem() return } st := block.NewState(bid, 0) @@ -806,24 +1806,72 @@ func newMeshBuilder(reg *block.Registry, uvFn func(string) (u0, v0, u1, v1 float return b } -// computeFistHand 空手/非方块物品:肤色拳头(白羊毛纹理 × 肤色 tint,渲染.md §5.2)。 -func (a *clientApp) computeFistHand() { - u0, v0, u1, v1, found := a.uv("block/white_wool") - if !found { - u0, v0, u1, v1 = 0, 0, 1, 1 - } - uvs := [4]float32{u0, v0, u1, v1} +// clearHeldItem 空手:只画皮肤手臂,不再铺白羊毛拳头(渲染.md §5.2)。 +func (a *clientApp) clearHeldItem() { a.mu.Lock() - a.handUVs = [6][4]float32{uvs, uvs, uvs, uvs, uvs, uvs} - a.handHas = true + a.handHas = false a.handItemHas = false - a.handTint = [3]float32{0.85, 0.65, 0.5} // 肤色 a.handDirty = true a.mu.Unlock() } +// integerGUIScale 按窗口像素取 1–4 整数缩放(UI还原.md §3.1:427×240 基准)。 +func integerGUIScale(w, h int) float32 { + s := w / 427 + if h/240 < s { + s = h / 240 + } + if s < 1 { + s = 1 + } + if s > 4 { + s = 4 + } + return float32(s) +} + +// applyResize 同步 viewport 之后的 UI / 投影尺寸(渲染.md §5.4)。 +func (a *clientApp) applyResize(w, h int) { + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + a.winW, a.winH = w, h + a.guiScale = integerGUIScale(w, h) + if a.uiR != nil { + a.uiR.Resize(w, h, a.guiScale) + } + if a.hud != nil { + a.hud.SetScale(a.guiScale) + } + if a.titleScreen != nil { + a.titleScreen.Layout(float32(w), float32(h), a.guiScale) + } + if a.pauseScreen != nil { + a.pauseScreen.Layout(float32(w), float32(h), a.guiScale) + } + if a.deathScreen != nil { + a.deathScreen.Layout(float32(w), float32(h), a.guiScale) + } + if a.selectWorld != nil { + a.selectWorld.Layout(float32(w), float32(h), a.guiScale) + } + if a.createWorld != nil { + a.createWorld.Layout(float32(w), float32(h), a.guiScale) + } + if a.optionsScr != nil { + a.optionsScr.Layout(float32(w)) + } +} + // Render 每帧渲染(渲染线程):上传待传网格 → 视图矩阵 → 三 pass 绘制。 func (a *clientApp) Render() { + if a.in.toggleFS { + a.in.toggleFS = false + a.rend.ToggleFullscreen() + } // 消费光标模式请求(主线程执行 GLFW 调用) if req := a.cursorReq.Load(); req >= 0 { a.rend.SetCursorMode(req == 1) @@ -834,6 +1882,29 @@ func (a *clientApp) Render() { } a.cursorReq.Store(-1) } + if a.uiListDirty.CompareAndSwap(true, false) { + a.refreshWorldList() + } + if a.uiCreateDirty.CompareAndSwap(true, false) { + a.refreshCreateFields() + } + if a.clearChunks.CompareAndSwap(true, false) { + a.rend.ClearAllChunks() + a.mu.Lock() + old := a.retireWorld + a.retireWorld = nil + a.mu.Unlock() + if old != nil { + old.Close() + } + } + a.mu.Lock() + rm := a.pendingRemove + a.pendingRemove = nil + a.mu.Unlock() + for _, p := range rm { + a.rend.RemoveChunk(p[0], p[1]) + } // dev 测试:挖坑类测试提前注入(留足网格重建时间);其余截图前注入 if a.frames == 100 && *digTest && !a.digShot { a.digShot = true @@ -862,17 +1933,22 @@ func (a *clientApp) Render() { mesh.Release(j.m) // 上传完毕归还顶点池(性能与内存.md §2) } - // 视图投影 - cam := a.sess.Cam - aspect := float64(a.winW) / float64(a.winH) - a.rend.SetViewProj(cam.View(), cam.Projection(aspect, 0.05, 1024)) - // 清屏 + 绘制(视锥剔除简化为全量绘制,后置 LOD) a.rend.BeginFrame() + fw, fh := a.rend.FramebufferSize() + if fw != a.winW || fh != a.winH { + a.applyResize(fw, fh) + } + cam := a.sess.Cam + if a.screen == 0 || a.screen == 6 || a.screen == 7 { + cam = a.menuOrbitCam() + } + aspect := float64(a.winW) / float64(a.winH) + a.rend.SetViewProj(cam.View(), cam.Projection(aspect, 0.05, 1024)) chunks := a.sess.World.ActiveChunks() a.rend.DrawChunks(func(i int) (int32, int32) { return chunks[i].CX, chunks[i].CZ }, len(chunks)) - // 实体绘制(渲染.md §5.3:动物方块模型;僵尸/村民/掉落物广告牌) + // 实体绘制(渲染.md §5.3:动物/人形盒模型;掉落物广告牌) for _, e := range a.sess.Entities.List() { if e.Dead { continue @@ -881,16 +1957,33 @@ func (a *clientApp) Render() { if key == "" { continue } - if uv, ok := a.rend.EntityUV(key); ok { - if variant >= 0 { - // 动物:方块组合模型,始终面朝相机(原版观感) - yaw := float32(math.Atan2(a.sess.Cam.Pos[0]-e.Pos[0], a.sess.Cam.Pos[2]-e.Pos[2])) - a.rend.DrawAnimalModel(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), yaw, animalModel(variant), uv, e.HitFlash > 0) - } else if e.Kind == entity.KindItemDrop { - a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h, uv, false) - } else { - a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h*0.6, uv, e.HitFlash > 0) + if variant >= 0 { + uv, ok := firstUV(a.rend, animalSkinKeys(variant)...) + if !ok { + uv, ok = a.rend.EntityUV("entity/missing") } + if ok { + yaw := float32(e.Yaw) + a.rend.DrawAnimalModel(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), yaw, render.AnimalModelByVariant(variant), uv, e.HitFlash > 0) + if variant == 2 { + if fur, furOK := firstUV(a.rend, "entity/sheep/sheep_fur", "entity/sheep/sheep_wool"); furOK { + a.rend.DrawAnimalModel(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), yaw, render.SheepWoolModel(), fur, e.HitFlash > 0) + } + } + } + continue + } + uv, ok := a.rend.EntityUV(key) + if !ok { + uv, ok = a.rend.EntityUV("entity/missing") + } + if !ok { + continue + } + if e.Kind == entity.KindItemDrop { + a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h, uv, false) + } else { + a.rend.DrawAnimalModel(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), float32(e.Yaw), render.HumanoidModel(), uv, e.HitFlash > 0) } } @@ -913,36 +2006,21 @@ func (a *clientApp) Render() { } } a.mu.Lock() - handDirty, handHas := a.handDirty, a.handHas + handHas := a.handHas handItemHas, handItemUV := a.handItemHas, a.handItemUV - a.handDirty = false var handUVs [6][4]float32 - var handTint [3]float32 if handHas { handUVs = a.handUVs - handTint = a.handTint } + a.handDirty = false a.mu.Unlock() - swingPos := [3]float32{0.46, -0.34, -0.66} - if a.swing > 0 { - swingPos[0] -= float32(a.swing) * 0.15 // 挥动:向屏幕中心下方摆动(保持手部可见) - swingPos[1] -= float32(a.swing) * 0.10 - handDirty = true // 挥动期间逐帧重传位置 - } - if handDirty && !handItemHas { - if handHas { - a.rend.SetHandCube(handUVs, swingPos, 0.16) - } else { - a.rend.ClearHand() - } - } if a.screen == 1 { - if handItemHas { - // 手持非放置物品:物品图标四边形(随挥动位置移动) - a.rend.DrawHandItem(handItemUV, swingPos, 0.22) - } else { - a.rend.DrawHand(handTint) + armUV, hasSkin := firstUV(a.rend, "entity/player/wide/steve", "entity/steve") + if !hasSkin && !a.armWarned { + a.armWarned = true + a.logf("WARN 玩家皮肤缺失(entity/player/wide/steve、entity/steve),第一人称手臂使用肤色回退") } + a.rend.DrawFirstPerson(armUV, hasSkin, float32(a.swing), handUVs, handHas, handItemUV, handItemHas) } // 准星指向方块名上传(tick 光栅化 → 渲染线程上传,UI还原.md §3.4) @@ -953,11 +2031,63 @@ func (a *clientApp) Render() { if targetDirty { if targetImg != nil { tex := a.uiR.UploadImage(targetImg) - a.hud.SetTarget(tex, float32(targetImg.Bounds().Dx()), float32(targetImg.Bounds().Dy())) + a.hud.SetTarget(tex, float32(targetImg.Bounds().Dx()), float32(targetImg.Bounds().Dy()), a.targetName) } else { - a.hud.SetTarget(0, 0, 0) + a.hud.SetTarget(0, 0, 0, "") } } + a.mu.Lock() + tipDirty, tipImg := a.tipDirty, a.tipImg + a.tipDirty = false + a.mu.Unlock() + if tipDirty { + if tipImg != nil { + tex := a.uiR.UploadImage(tipImg) + tw, th := float32(tipImg.Bounds().Dx()), float32(tipImg.Bounds().Dy()) + a.invScreen.SetTooltip(tex, tw, th) + a.craftScreen.SetTooltip(tex, tw, th) + if a.furnaceScreen != nil { + a.furnaceScreen.SetTooltip(tex, tw, th) + } + if a.chestScreen != nil { + a.chestScreen.SetTooltip(tex, tw, th) + } + if a.creativeScreen != nil { + a.creativeScreen.SetTooltip(tex, tw, th) + } + } else { + a.invScreen.SetTooltip(0, 0, 0) + a.craftScreen.SetTooltip(0, 0, 0) + if a.furnaceScreen != nil { + a.furnaceScreen.SetTooltip(0, 0, 0) + } + if a.chestScreen != nil { + a.chestScreen.SetTooltip(0, 0, 0) + } + if a.creativeScreen != nil { + a.creativeScreen.SetTooltip(0, 0, 0) + } + } + } + + a.mu.Lock() + capDirty, capImgs := a.captionDirty, a.captionImgs + a.captionDirty = false + a.mu.Unlock() + if capDirty { + var tex []uint32 + var cw, ch []float32 + for _, img := range capImgs { + if img == nil { + continue + } + tex = append(tex, a.uiR.UploadImage(img)) + b := img.Bounds() + cw = append(cw, float32(b.Dx())) + ch = append(ch, float32(b.Dy())) + } + a.hud.SetCaptions(tex, cw, ch) + } // 小地图上传(tick 生成地表色图 → 渲染线程上传,UI还原.md §3.5) a.mu.Lock() @@ -974,23 +2104,67 @@ func (a *clientApp) Render() { mx, my := float32(a.in.cursorX), float32(a.in.cursorY) switch a.screen { case 0: + a.titleScreen.SetWorldBg(true) a.titleScreen.Draw(a.uiR, mx, my) case 1: a.hud.Draw(a.uiR) + a.drawPickupFlies() case 2: a.hud.Draw(a.uiR) // 暂停时世界仍可见(UI还原.md §2) a.pauseScreen.Draw(a.uiR, mx, my) + a.drawSaveHint() + case 13: + if a.loadingScreen != nil { + a.loadingScreen.Draw(a.uiR) + } + case 11: + a.hud.Draw(a.uiR) + a.deathScreen.Cause = a.sess.DeathReason() + a.deathScreen.Draw(a.uiR, mx, my) + case 12: + a.tradeScreen.Draw(a.uiR, a.sess.TradeOffers(), mx, my, a.guiScale) case 3: // 工作台合成界面(UI还原.md §3.4) var grid [9]item.Stack for i := range grid { grid[i] = a.sess.CraftSlot(i) } res, hasRes := a.sess.CraftingResult() - a.craftScreen.Draw(a.uiR, grid, res, hasRes, a.sess.Inv.Slots[:inventory.HotbarSize], a.held, mx, my, 3) + a.craftScreen.Draw(a.uiR, grid, res, hasRes, a.sess.Inv.Slots, a.held, a.sess.Recipes.CraftingRecipes(), a.sess.Unlocked, mx, my, a.guiScale) case 4: // 设置界面 a.optionsScr.Draw(a.uiR, mx, my, float32(a.sensVal/0.01), float32(a.volVal)) case 5: // 物品栏界面 - a.invScreen.Draw(a.uiR, a.sess.Inv.Slots, a.held, mx, my, 3) + var cg [4]item.Stack + for i := range cg { + cg[i] = a.sess.InvCraftSlot(i) + } + ires, ihas := a.sess.InvCraftResult() + a.invScreen.Draw(a.uiR, a.sess.Inv.Slots, cg, ires, ihas, a.held, mx, my, a.guiScale) + case 8: + be := a.sess.ContainerEntity() + var in, fuel, out item.Stack + var burn, cook float32 + if be != nil && len(be.Slots) > 2 { + in, fuel, out = be.Slots[1], be.Slots[0], be.Slots[2] + if be.BurnMaxTicks > 0 { + burn = float32(be.BurnTicks) / float32(be.BurnMaxTicks) + } + cook = float32(be.CookTicks) / float32(blockentity.CookTime) + } + a.furnaceScreen.Draw(a.uiR, in, fuel, out, burn, cook, a.sess.Inv.Slots, a.held, mx, my, a.guiScale) + case 9: + var box []item.Stack + if be := a.sess.ContainerEntity(); be != nil { + box = be.Slots + } + a.chestScreen.Draw(a.uiR, box, a.sess.Inv.Slots, a.held, mx, my, a.guiScale) + case 10: + a.creativeScreen.Draw(a.uiR, a.sess.Inv.Slots, a.held, mx, my, a.guiScale) + case 6: + a.titleScreen.SetWorldBg(true) + a.selectWorld.Draw(a.uiR, mx, my) + case 7: + a.titleScreen.SetWorldBg(true) + a.createWorld.Draw(a.uiR, mx, my) } a.uiR.Flush() a.rend.EndFrame() @@ -1037,11 +2211,11 @@ func (a *clientApp) logf(format string, args ...any) { } func main() { - crash.SetLogPath(paths.Join("logs", "crash.log")) // 全局崩溃日志(含 goroutine 兜底) - defer crash.Guard(paths.Join("logs", "crash.log")) // 主线程崩溃兜底:闪退可诊断 + crash.SetLogPath(paths.Join("logs", "crash.log")) + defer crash.Guard(paths.Join("logs", "crash.log")) flag.Parse() - log, err := logx.New(paths.Join("logs", "client.log"), logx.LevelInfo) + log, err := logx.NewFile(paths.Join("logs", "client.log"), logx.LevelInfo) if err != nil { panic(err) } @@ -1051,352 +2225,27 @@ func main() { } log.Infof("%s 客户端启动:%dx%d,语言 %s,视距 %d", cfg.Client.WindowTitle, cfg.Client.WindowWidth, cfg.Client.WindowHeight, cfg.Client.Language, cfg.Client.RenderDistance) - // 1) 内容包与图集(设计.md §1.3:扫描 mods/ + resourcepacks/) - am := assets.New() - if err := am.Load(paths.Join("assets"), paths.Join("mods"), paths.Join("resourcepacks")); err != nil { - log.Warnf("内容包加载失败:%v", err) - } - log.Infof("已加载 %d 个内容包", len(am.Packs())) - - keys, err := am.ExpandAtlas("blocks") + app, err := bootClient(log, cfg) if err != nil { + if errors.Is(err, errBootClosed) { + log.Infof("启动中关闭窗口") + return + } panic(err) } - atlasImg, uvRects, err := atlas.Build(am.ResolveTexture, keys, 16) - if err != nil { - panic(err) - } - log.Infof("blocks 图集:%d 纹理,%dx%d", len(uvRects), atlasImg.Bounds().Dx(), atlasImg.Bounds().Dy()) - - // UV 函数(渲染.md §4:inset 防渗色) - uvFn := func(texKey string) (u0, v0, u1, v1 float32, ok bool) { - rect, found := uvRects[texKey] - if !found { - return 0, 0, 1, 1, false - } - W, H := float32(atlasImg.Bounds().Dx()), float32(atlasImg.Bounds().Dy()) - const inset = 0.5 - u0 = (float32(rect.Min.X) + inset) / W - v0 = (float32(rect.Min.Y) + inset) / H - u1 = (float32(rect.Max.X) - inset) / W - v1 = (float32(rect.Max.Y) - inset) / H - return u0, v0, u1, v1, true - } - - // items 图集(HUD 热键栏图标,UI还原.md §7) - itemKeys, err := am.ExpandAtlas("items") - if err != nil { - log.Warnf("items 图集展开失败: %v", err) - } - itemImg, itemUVs, err := atlas.Build(am.ResolveTexture, itemKeys, 16) - if err != nil { - log.Warnf("items 图集构建失败: %v", err) - } - - // 字体(UI还原.md §4:素材包位图字体) - fontAtlas, err := font.Build(am, "default") - if err != nil { - log.Warnf("字体构建失败: %v", err) - } - - // 2) 注册表(设计.md §6.1/§6.2 数据驱动) - reg, err := block.Load(paths.Join("assets", "config", "blocks.json")) - if err != nil { - panic(err) - } - items, err := item.Load(paths.Join("assets", "config", "items.json")) - if err != nil { - panic(err) - } - recipes, err := recipe.Load(paths.Join("assets", "config", "recipes.json")) - if err != nil { - panic(err) - } - - // 植被染色(渲染.md §4.1):原版材质包草顶/树叶为灰度图, - // 绿色来自染色表——图集构建后对带 tint 标记的纹理乘色,否则草地/树叶全灰。 - if tinted := reg.TintTextureKeys(); len(tinted) > 0 { - atlasImg = applyAtlasTint(atlasImg, uvRects, am, tinted) - log.Infof("图集植被染色:%v", tinted) - } - - // 小地图配色表(方块 ID → 图集中心色,UI还原.md §3.5) - blockColors := buildBlockColors(reg, atlasImg, uvRects) - - // 裂纹图集(方块交互与动画.md §4:destroy_stage_0..9,破坏进度叠加用) - crackKeys := make([]string, 10) - for i := range crackKeys { - crackKeys[i] = fmt.Sprintf("block/destroy_stage_%d", i) - } - crackImg, crackRectMap, err := atlas.Build(am.ResolveTexture, crackKeys, 16) - if err != nil { - log.Warnf("裂纹图集构建失败:%v", err) - } - - // 实体贴图图集(渲染.md §5.3:僵尸/村民/动物 + 掉落物物品图标) - entityKeys := []string{ - "entity/zombie/zombie", - "entity/villager/villager", - "entity/cow/cow_temperate", - "entity/pig/pig_temperate", - "entity/sheep/sheep", - "entity/chicken/chicken_temperate", - } - // 掉落物图标:全部物品纹理并入实体图集(16px 最近邻放大到 64px tile) - seen := make(map[string]struct{}, len(entityKeys)+64) - for _, k := range entityKeys { - seen[k] = struct{}{} - } - for _, name := range items.Names() { - if d, ok := items.Get(name); ok { - if _, dup := seen[d.Texture]; !dup { - seen[d.Texture] = struct{}{} - entityKeys = append(entityKeys, d.Texture) - } - } - } - entityImg, entityRectMap, err := atlas.Build(am.ResolveTexture, entityKeys, 64) - if err != nil { - log.Warnf("实体图集构建失败:%v", err) - } - - // 3) 世界与生成器(区块管理.md §4 异步流水线;种子来自 client.yaml world.seed) - worldSeed := cfg.Client.World.Seed - worldName := cfg.Client.World.Name - if worldName == "" { - worldName = "NewWorld" - } - log.Infof("世界种子 %d,世界名 %s", worldSeed, worldName) - gen, err := worldgen.New(reg, worldSeed, 0.005, 0.01, 0.1) - if err != nil { - panic(err) - } - w, err := world.New(reg, gen, cfg.Client.RenderDistance) - if err != nil { - panic(err) - } - defer w.Close() - - // 单机存档(存档与持久化.md §3:diff-only 载入,无存档则新建) - if _, err := os.Stat(paths.Join("worlds", worldName, "level.dat")); err == nil { - if _, err := save.LoadWorld(w, paths.Join("worlds"), worldName); err != nil { - log.Warnf("存档载入失败: %v", err) - } else { - log.Infof("已载入世界 %s", worldName) - } - } - - // 4) 游戏会话(出生逻辑:先等出生区块加载,再定位地表出生—— - // 否则玩家从高空坠落会穿过未加载区块掉进地下,游戏内一片漆黑) - var surf int32 = 120 - for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); { - w.Update(8.5, 100, 8.5, 8*time.Millisecond) - if w.Block(8, 80, 8) != block.Air { - break - } - time.Sleep(2 * time.Millisecond) - } - for y := int32(255); y > 0; y-- { - if w.Block(8, y, 8) != block.Air { - surf = y - break - } - } - p := physics.NewPlayer(8.5, float64(surf+2), 8.5) - cam := camera.New(p.Eye(), 0, -0.35, cfg.Client.FOV) // 略微俯视,视野内以地形为主 - inv := inventory.New(items) - // dev 测试:给 0 号槽物品(验证手持方块渲染) - if *giveItem != "" { - if inv.Add(item.Stack{Name: *giveItem, Count: 64}).Count != 0 { - log.Warnf("give 失败(物品不存在或已满): %s", *giveItem) - } else { - log.Infof("dev give: %s ×64 → 热键栏 0 号槽", *giveItem) - } - } - sess := game.NewSession(w, reg, items, recipes, p, inv, cam) - - // 音频(音频.md §2:事件驱动 + 3D 衰减;无音频设备自动降级静默) - audioMgr := audio.New() - defer audioMgr.Close() - audioMgr.SetVolumes(cfg.Client.Audio.Volume, 1, 1, 1) - sess.Sound = func(key string, x, y, z float64) { audioMgr.Play(key, x, y, z) } - - // 5) 渲染器(GL 线程亲和在其内部锁定) - rend, err := render.New(cfg.Client.WindowTitle, cfg.Client.WindowWidth, cfg.Client.WindowHeight) - if err != nil { - panic(err) - } - defer rend.Shutdown() - rend.SetAtlas(atlasImg) - // 窗口图标:素材包 pack.png(原版 logo,构建与发布.md §8) - for _, p := range am.Packs() { - iconPath := paths.Join(p.Root, "pack.png") - if f, err := os.Open(iconPath); err == nil { - if img, err := png.Decode(f); err == nil { - rend.SetWindowIcon(img) - } - f.Close() - break - } - } - // 主菜单状态:鼠标可见(仅游戏内捕获,UI与输入.md §6) - rend.SetCursorMode(false) - // 裂纹图集上传(方块交互与动画.md §4) - if crackImg != nil { - var crackRects [10]image.Rectangle - for i := 0; i < 10; i++ { - crackRects[i] = crackRectMap[crackKeys[i]] - } - rend.SetCrackAtlas(crackImg, crackRects) - } - // 实体贴图上传(渲染.md §5.3) - if entityImg != nil { - entityUV := make(map[string][4]float32, len(entityKeys)) - W, H := float32(entityImg.Bounds().Dx()), float32(entityImg.Bounds().Dy()) - for _, k := range entityKeys { - rc := entityRectMap[k] - entityUV[k] = [4]float32{ - float32(rc.Min.X) / W, float32(rc.Min.Y) / H, - float32(rc.Max.X) / W, float32(rc.Max.Y) / H, - } - } - rend.SetEntityAtlas(entityImg, entityUV) - } - - // 光影包(渲染.md §9:未安装 → 默认光影;坏包 → 回退默认) - if sp, err := render.LoadShaderPack(am); err != nil { - log.Warnf("光影包加载失败(使用默认光影):%v", err) - } else if sp != nil { - if err := rend.ApplyShaderPack(sp); err != nil { - log.Warnf("光影包应用失败(使用默认光影):%v", err) - } else { - log.Infof("已加载光影包 %q", sp.ID) - } - } - - // UI 层(UI还原.md §3.1:427×240 基准 × 3 缩放 = 1280×720) - guiScale := float32(3) - uiR, err := ui.New(cfg.Client.WindowWidth, cfg.Client.WindowHeight, guiScale) - if err != nil { - panic(err) - } - if fontAtlas != nil { - uiR.SetFont(fontAtlas) - } - hud := ui.NewHUD(sess, items, 427, 240, guiScale) - var itemTex uint32 - itemUV := make(map[string][4]float32, len(itemUVs)) - if itemImg != nil { - itemTex = uiR.UploadImage(itemImg) - for k, rect := range itemUVs { - W, H := float32(itemImg.Bounds().Dx()), float32(itemImg.Bounds().Dy()) - itemUV[k] = [4]float32{ - float32(rect.Min.X) / W, float32(rect.Min.Y) / H, - float32(rect.Max.X) / W, float32(rect.Max.Y) / H, - } - } - hud.SetItemAtlas(itemTex, itemUV) - } - // 方块物品图标回退 blocks 图集(原版 block/* 图标不在 items 图集) - blockUITex := uiR.UploadImage(atlasImg) - blockUV := make(map[string][4]float32, len(uvRects)) - for k, rect := range uvRects { - W, H := float32(atlasImg.Bounds().Dx()), float32(atlasImg.Bounds().Dy()) - blockUV[k] = [4]float32{ - float32(rect.Min.X) / W, float32(rect.Min.Y) / H, - float32(rect.Max.X) / W, float32(rect.Max.Y) / H, - } - } - hud.SetBlockAtlas(blockUITex, blockUV) - // 原版 HUD 精灵(UI还原.md §3.4:gui/sprites/hud/*) - loadTex := func(key string) uint32 { - p, ok := am.ResolveTexture(key) - if !ok { - return 0 - } - f, err := os.Open(p) - if err != nil { - return 0 - } - img, err := png.Decode(f) - _ = f.Close() - if err != nil { - return 0 - } - return uiR.UploadImage(img) - } - hud.SetSprites( - loadTex("gui/sprites/hud/crosshair"), - loadTex("gui/sprites/hud/hotbar"), - loadTex("gui/sprites/hud/hotbar_selection"), - loadTex("gui/sprites/hud/heart/full"), - loadTex("gui/sprites/hud/heart/half"), - loadTex("gui/sprites/hud/heart/container"), - loadTex("gui/sprites/hud/food_full"), - loadTex("gui/sprites/hud/food_half"), - ) - - // 界面屏幕(原版复刻:土质背景/原版按钮/中文标题,UI还原.md §2) - lang, err := am.LoadLang(cfg.Client.Language) - if err != nil { - log.Warnf("语言加载失败: %v", err) - } - titleScreen, pauseScreen := buildScreens(uiR, am, lang) - - // 工作台合成界面(UI还原.md §3.4:gui/container/crafting_table.png + 3×3 网格) - craftScreen := ui.NewCraftingScreen(loadTex("gui/container/crafting_table"), items) - craftScreen.SetItemAtlas(itemTex, itemUV) - craftScreen.SetBlockAtlas(blockUITex, blockUV) - - // 设置界面(灵敏度/音量/种子/按键教程,UI与输入.md §6) - optionsScr := buildOptionsScreen(uiR, lang, - loadTex("gui/sprites/widget/button"), loadTex("gui/sprites/widget/button_highlighted"), worldSeed) - - // 物品栏界面(UI还原.md §3.3:E 键打开) - invScreen := ui.NewInventoryScreen(loadTex("gui/container/inventory"), items) - invScreen.SetItemAtlas(itemTex, itemUV) - invScreen.SetBlockAtlas(blockUITex, blockUV) - - // 6) 输入(鼠标捕获 + 键鼠回调) - in := newInputState(rend.Window()) - in.SetSensitivity(cfg.Client.MouseSensitivity) - // 光标初始位置 = 窗口中心(捕获模式下 GLFW 会把光标重置到中心; - // 合成界面手持栈跟随光标,初始化为中心避免出现在左上角) - in.cursorX, in.cursorY = float64(cfg.Client.WindowWidth)/2, float64(cfg.Client.WindowHeight)/2 - - app := &clientApp{ - sess: sess, - rend: rend, - reg: reg, - in: in, - uiR: uiR, - hud: hud, - titleScreen: titleScreen, - pauseScreen: pauseScreen, - craftScreen: craftScreen, - optionsScr: optionsScr, - invScreen: invScreen, - audio: audioMgr, - worldSeed: worldSeed, - worldName: worldName, - sensVal: cfg.Client.MouseSensitivity, - volVal: cfg.Client.Audio.Volume, - blockColors: blockColors, - log: log, - lang: lang, - remotePlayers: make(map[uint32][3]float64), - meshBuilder: newMeshBuilder(reg, uvFn, sess), - uv: uvFn, - handSlot: -1, - winW: cfg.Client.WindowWidth, - winH: cfg.Client.WindowHeight, - lastTime: time.Now(), + defer app.rend.Shutdown() + defer app.audio.Close() + if app.sess != nil && app.sess.World != nil { + defer app.sess.World.Close() } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - log.Infof("进入游戏主循环") engine.Run(ctx, app, app) + if app.persistOnExit { + app.persistWorldWait() + } + app.savePool.Close() log.Infof("客户端已退出") } diff --git a/cmd/client/entities_gl.go b/cmd/client/entities_gl.go index d82f2505..c48bfa8f 100644 --- a/cmd/client/entities_gl.go +++ b/cmd/client/entities_gl.go @@ -1,8 +1,6 @@ //go:build gl // 实体可视映射:实体类型 → 贴图键/身高/动物模型(渲染.md §5.3)。 -// 动物用方块组合模型(身体+头+四腿)近似原版外观;僵尸/村民用交叉广告牌; -// 掉落物用物品图标小广告牌。 package main import ( @@ -11,8 +9,45 @@ import ( "mc/internal/render" ) +// entitySkinKeys 实体皮肤候选(temperate 优先,回退短名)。 +var entitySkinKeys = []string{ + "entity/player/wide/steve", + "entity/steve", + "entity/zombie/zombie", + "entity/villager/villager", + "entity/cow/cow_temperate", + "entity/cow/cow", + "entity/pig/pig_temperate", + "entity/pig/pig", + "entity/sheep/sheep_temperate", + "entity/sheep/sheep", + "entity/sheep/sheep_fur", + "entity/sheep/sheep_wool", + "entity/chicken/chicken_temperate", + "entity/chicken/chicken", +} + +// requiredEntitySkinGroups 必选皮肤候选组:组内任一键命中即可;全缺才品红占位。 +var requiredEntitySkinGroups = [][]string{ + {"entity/zombie/zombie"}, + {"entity/villager/villager"}, + {"entity/cow/cow_temperate", "entity/cow/cow"}, + {"entity/pig/pig_temperate", "entity/pig/pig"}, + {"entity/sheep/sheep_temperate", "entity/sheep/sheep"}, + {"entity/chicken/chicken_temperate", "entity/chicken/chicken"}, +} + +func firstUV(r *render.Renderer, keys ...string) ([4]float32, bool) { + for _, k := range keys { + if uv, ok := r.EntityUV(k); ok { + return uv, true + } + } + return [4]float32{}, false +} + // entityVisual 返回实体的贴图键与身高(米)。 -// 动物额外返回外观变体(0 牛 / 1 猪 / 2 羊 / 3 鸡),供方块模型选择。 +// 动物额外返回外观变体(0 牛 / 1 猪 / 2 羊 / 3 鸡)。 func entityVisual(e *entity.Entity, items *item.Registry) (key string, height float32, variant int) { switch e.Kind { case entity.KindZombie: @@ -20,7 +55,7 @@ func entityVisual(e *entity.Entity, items *item.Registry) (key string, height fl case entity.KindVillager: return "entity/villager/villager", 1.95, -1 case entity.KindAnimal: - switch e.ID % 4 { + switch entity.AnimalSpecies(e) { case 0: return "entity/cow/cow_temperate", 1.4, 0 case 1: @@ -45,82 +80,15 @@ func entityVisual(e *entity.Entity, items *item.Registry) (key string, height fl } } -// rect 构造图集 tile 内归一化子矩形。 -func rect(u0, v0, u1, v1 float32) [4]float32 { return [4]float32{u0, v0, u1, v1} } - -// boxPart 构造模型部件:六面统一用 side 矩形,前/后取中间条带,顶取上条带,底取下条带。 -func boxPart(cx, cy, cz, w, h, d float32, side [4]float32) render.AnimalPart { - u0, v0, u1, v1 := side[0], side[1], side[2], side[3] - uw, vh := u1-u0, v1-v0 - front := rect(u0+uw*0.35, v0, u0+uw*0.65, v1) // 前/后面:中间三分之一 - top := rect(u0, v0, u1, v0+vh*0.3) - bottom := rect(u0, v1-vh*0.3, u1, v1) - var uv [6][4]float32 - // 顺序同 cubeFaces:顶/底/东/西/南(前)/北(后) - uv[0], uv[1] = top, bottom - uv[2], uv[3] = side, side - uv[4], uv[5] = front, front - return render.AnimalPart{CX: cx, CY: cy, CZ: cz, W: w, H: h, D: d, UV: uv} -} - -// animalModel 返回动物方块模型(variant:0 牛 / 1 猪 / 2 羊 / 3 鸡)。 -// 贴图子矩形按原版皮肤布局:头部在纹理左侧、身体在右侧、腿在下部。 -// 图集 tile 内纹理被等比拉伸成 64×64(64×32 原图纵向拉长一倍,矩形已按拉伸后比例折算)。 -func animalModel(variant int) render.AnimalModel { +func animalSkinKeys(variant int) []string { switch variant { - case 0: // 牛:纹理 64×64,内容在上半(v 0–0.5) - head := rect(0.02, 0.02, 0.48, 0.48) - body := rect(0.52, 0.02, 0.98, 0.48) - legs := rect(0.55, 0.30, 0.98, 0.50) - var m render.AnimalModel - m.Parts = append(m.Parts, - boxPart(0, 0.72, 0, 0.92, 0.62, 1.28, body), - boxPart(0, 1.26, 0.60, 0.56, 0.50, 0.56, head), - boxPart(-0.34, 0.24, 0.50, 0.22, 0.48, 0.22, legs), - boxPart(0.34, 0.24, 0.50, 0.22, 0.48, 0.22, legs), - boxPart(-0.34, 0.24, -0.50, 0.22, 0.48, 0.22, legs), - boxPart(0.34, 0.24, -0.50, 0.22, 0.48, 0.22, legs), - ) - return m - case 1: // 猪:纹理 64×64,内容在上半 - head := rect(0.14, 0.03, 0.48, 0.45) - body := rect(0.52, 0.03, 0.98, 0.45) - legs := rect(0.60, 0.30, 0.98, 0.50) - var m render.AnimalModel - m.Parts = append(m.Parts, - boxPart(0, 0.50, 0, 0.76, 0.44, 1.02, body), - boxPart(0, 0.86, 0.50, 0.50, 0.42, 0.50, head), - boxPart(-0.28, 0.15, 0.42, 0.18, 0.30, 0.18, legs), - boxPart(0.28, 0.15, 0.42, 0.18, 0.30, 0.18, legs), - boxPart(-0.28, 0.15, -0.42, 0.18, 0.30, 0.18, legs), - boxPart(0.28, 0.15, -0.42, 0.18, 0.30, 0.18, legs), - ) - return m - case 2: // 羊:纹理 64×32(tile 内 v 拉长一倍) - head := rect(0.02, 0.02, 0.26, 0.40) - body := rect(0.50, 0.15, 1.00, 0.72) - legs := rect(0.62, 0.50, 0.95, 0.85) - var m render.AnimalModel - m.Parts = append(m.Parts, - boxPart(0, 0.72, 0, 0.92, 0.60, 1.26, body), - boxPart(0, 1.20, 0.60, 0.50, 0.46, 0.50, head), - boxPart(-0.33, 0.21, 0.50, 0.20, 0.42, 0.20, legs), - boxPart(0.33, 0.21, 0.50, 0.20, 0.42, 0.20, legs), - boxPart(-0.33, 0.21, -0.50, 0.20, 0.42, 0.20, legs), - boxPart(0.33, 0.21, -0.50, 0.20, 0.42, 0.20, legs), - ) - return m - default: // 鸡:纹理 64×32 - head := rect(0.02, 0.05, 0.28, 0.55) - body := rect(0.28, 0.10, 0.70, 0.75) - legs := rect(0.30, 0.55, 0.60, 0.80) - var m render.AnimalModel - m.Parts = append(m.Parts, - boxPart(0, 0.36, 0, 0.44, 0.40, 0.52, body), - boxPart(0, 0.66, 0.24, 0.32, 0.30, 0.32, head), - boxPart(-0.14, 0.10, 0.18, 0.09, 0.20, 0.09, legs), - boxPart(0.14, 0.10, 0.18, 0.09, 0.20, 0.09, legs), - ) - return m + case 0: + return []string{"entity/cow/cow_temperate", "entity/cow/cow"} + case 1: + return []string{"entity/pig/pig_temperate", "entity/pig/pig"} + case 2: + return []string{"entity/sheep/sheep_temperate", "entity/sheep/sheep"} + default: + return []string{"entity/chicken/chicken_temperate", "entity/chicken/chicken"} } } diff --git a/cmd/client/input.go b/cmd/client/input.go index 6785850f..82329285 100644 --- a/cmd/client/input.go +++ b/cmd/client/input.go @@ -31,6 +31,7 @@ type inputState struct { breakDown bool breakHeld bool placeDown bool + placeHeld bool selectSlot int // -1 无 toggleInv bool // E 打开/关闭物品栏 dropItem bool // Q 丢弃选中槽 1 个物品 @@ -39,7 +40,13 @@ type inputState struct { cursorX, cursorY float64 clickX, clickY float32 clicked bool + clickedRight bool escPressed bool + toggleFS bool // F11 / Alt+Enter(渲染线程消费,渲染.md §5.4) + + textMode bool + chars []rune + backspace bool } // newInputState 创建输入状态并注册回调。 @@ -49,6 +56,7 @@ func newInputState(win *glfw.Window) *inputState { win.SetMouseButtonCallback(in.mouseButtonCallback) win.SetCursorPosCallback(in.cursorCallback) win.SetScrollCallback(in.scrollCallback) + win.SetCharCallback(in.charCallback) return in } @@ -63,7 +71,7 @@ func (in *inputState) SetSensitivity(s float64) { func (in *inputState) Sensitivity() float64 { return in.sens } // keyCallback 键盘回调(动作映射:WASD/空格/Shift/Ctrl/Esc)。 -func (in *inputState) keyCallback(_ *glfw.Window, key glfw.Key, _ int, action glfw.Action, _ glfw.ModifierKey) { +func (in *inputState) keyCallback(_ *glfw.Window, key glfw.Key, _ int, action glfw.Action, mods glfw.ModifierKey) { down := action != glfw.Release switch key { case glfw.KeyW: @@ -84,6 +92,10 @@ func (in *inputState) keyCallback(_ *glfw.Window, key glfw.Key, _ int, action gl if action == glfw.Press { in.escPressed = true // 暂停/继续由屏幕状态机处理 } + case glfw.KeyBackspace: + if action != glfw.Release && in.textMode { + in.backspace = true + } case glfw.Key1, glfw.Key2, glfw.Key3, glfw.Key4, glfw.Key5, glfw.Key6, glfw.Key7, glfw.Key8, glfw.Key9: if action == glfw.Press { in.selectSlot = int(key) - int(glfw.Key1) @@ -96,9 +108,24 @@ func (in *inputState) keyCallback(_ *glfw.Window, key glfw.Key, _ int, action gl if action == glfw.Press { in.dropItem = true // 丢弃物品(UI与输入.md §5) } + case glfw.KeyF11: + if action == glfw.Press { + in.toggleFS = true + } + case glfw.KeyEnter, glfw.KeyKPEnter: + if action == glfw.Press && mods&glfw.ModAlt != 0 { + in.toggleFS = true + } } } +func (in *inputState) charCallback(_ *glfw.Window, char rune) { + if !in.textMode { + return + } + in.chars = append(in.chars, char) +} + // mouseButtonCallback 鼠标按键:左键破坏(按住累积)、右键放置(UI与输入.md §6 长按)。 func (in *inputState) mouseButtonCallback(_ *glfw.Window, button glfw.MouseButton, action glfw.Action, _ glfw.ModifierKey) { switch button { @@ -113,7 +140,11 @@ func (in *inputState) mouseButtonCallback(_ *glfw.Window, button glfw.MouseButto case glfw.MouseButtonRight: if action == glfw.Press { in.placeDown = true + in.clickedRight = true + in.clickX = float32(in.cursorX) + in.clickY = float32(in.cursorY) } + in.placeHeld = action != glfw.Release } } @@ -184,10 +215,15 @@ func (in *inputState) apply(s *game.Session) { wish[1] -= r[2] } speed := 4.3 - if in.sprint { + if p.InWater { + speed = 2.2 + if in.sprint && in.forward { + speed = 5.4 + } + } else if in.sprint { speed *= 1.3 } - if in.sneak { + if in.sneak && !p.InWater { speed *= 0.3 } l := math.Sqrt(wish[0]*wish[0] + wish[1]*wish[1]) @@ -199,17 +235,25 @@ func (in *inputState) apply(s *game.Session) { p.Vel[2] = 0 } - // 跳跃 - if in.jump && p.OnGround { - p.Jump() + // 跳跃 / 游泳(物理与碰撞.md §4:水中空格上浮,不要求落地) + if in.jump { + if p.InWater { + p.SwimUp() + } else if p.OnGround { + p.Jump() + } } + if in.sneak && p.InWater { + p.SwimDown() + } + p.SetPose(s.World, in.sneak && !p.InWater, in.sprint && in.forward && p.InWater) - // 热键栏切换 + // 热键栏切换(UI与输入.md §6:上滚槽位−1,下滚+1,对齐原版 Java) switch in.selectSlot { case -2: - s.Inv.Selected = (s.Inv.Selected + 1) % 9 - case -3: s.Inv.Selected = (s.Inv.Selected + 8) % 9 + case -3: + s.Inv.Selected = (s.Inv.Selected + 1) % 9 default: if in.selectSlot >= 0 && in.selectSlot < 9 { s.Inv.Selected = in.selectSlot @@ -217,20 +261,28 @@ func (in *inputState) apply(s *game.Session) { } in.selectSlot = -1 - // 左键:优先攻击实体(动物体系.md §5),未命中再破坏方块(按住累积) + // 左键:按下边沿攻击一次;按住连续破坏(方块交互与动画.md §3) if in.breakDown { if !s.Attack() { + if _, ok := s.Cast(5); !ok { + s.SwingMiss() + } s.BeginBreak() } in.breakDown = false - } - if !in.breakHeld && s.BreakProgress() != 0 { + } else if in.breakHeld { + if !s.Breaking() { + s.BeginBreak() + } else { + s.ContinueBreak() + } + } else if s.Breaking() || s.BreakProgress() != 0 { s.EndBreak() } - // 放置(右键;命中工作台则打开合成界面,工作台与合成表.md §5) + // 放置(右键;命中工作台/熔炉/箱子则打开界面) if in.placeDown { - if !s.TryOpenCrafting() { + if !s.TryOpenCrafting() && !s.TryOpenFurnace() && !s.TryOpenChest() && !s.TryFlickLever() && !s.TryUseEntity() && !s.TryEat() { s.PlaceSelected() } in.placeDown = false diff --git a/cmd/client/screens_gl.go b/cmd/client/screens_gl.go index e684f39c..4aaeb743 100644 --- a/cmd/client/screens_gl.go +++ b/cmd/client/screens_gl.go @@ -92,7 +92,7 @@ func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.Ti // 按钮(原版 200×20、间距 24、居中,UI还原.md §2 布局) mkBtn := func(key, fallback, action string, y float32) ui.Button { tex, lw, lh := tr(key, fallback, 18, color.RGBA{255, 255, 255, 255}) - return ui.Button{X: 1280/2 - 100, Y: y, W: 200, H: 20, Tex: tex, LabelW: lw, LabelH: lh, Action: action} + return ui.Button{X: (427 - 200) / 2, Y: y, W: 200, H: 20, Tex: tex, LabelW: lw, LabelH: lh, Action: action} } title := &ui.TitleScreen{ TitleTex: titleTex, TitleW: tw, TitleH: th, @@ -109,10 +109,10 @@ func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.Ti title.CopyrightTex, title.CopyrightW, title.CopyrightH = cTex, cw, ch } title.Buttons = []ui.Button{ - mkBtn("menu.singleplayer", "单人游戏", "play", 390), - mkBtn("menu.multiplayer", "多人游戏", "multi", 414), - mkBtn("menu.options", "设置…", "options", 438), - mkBtn("menu.quit", "退出游戏", "quit", 462), + mkBtn("menu.singleplayer", "单人游戏", "select", 108), + mkBtn("menu.multiplayer", "多人游戏", "multi", 132), + mkBtn("menu.options", "设置…", "options", 156), + mkBtn("menu.quit", "退出游戏", "quit", 180), } pauseTitle, ptw, pth := tr("menu.game", "游戏菜单", 32, color.RGBA{255, 255, 255, 255}) @@ -121,13 +121,116 @@ func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.Ti BtnTex: btnTex, BtnHover: btnHover, } pause.Buttons = []ui.Button{ - mkBtn("gui.resume", "继续游戏", "resume", 300), - mkBtn("options.title", "设置…", "options", 324), - mkBtn("menu.quit", "保存并退出", "quit", 348), + mkBtn("gui.resume", "继续游戏", "resume", 72), + mkBtn("options.title", "设置…", "options", 94), + mkBtn("menu.save", "保存存档", "save", 116), + mkBtn("menu.saveAndQuit", "保存并退出", "saveQuit", 138), + mkBtn("menu.quitWorld", "退出游戏", "quitWorld", 160), } return title, pause } +func buildDeathScreen(r *ui.Renderer, lang *assets.Lang, btnTex, btnHover uint32) *ui.DeathScreen { + text := func(s string, size float64, c color.RGBA) (uint32, float32, float32) { + cjk := loadCJK(size) + if cjk == nil || s == "" { + return 0, 0, 0 + } + img := cjk.Draw(s, c) + return r.UploadImage(img), float32(img.Bounds().Dx()), float32(img.Bounds().Dy()) + } + tr := func(key, fallback string, size float64, c color.RGBA) (uint32, float32, float32) { + s := fallback + if lang != nil && lang.Get(key) != key { + s = lang.Get(key) + } + return text(s, size, c) + } + mkBtn := func(key, fallback, action string, y float32) ui.Button { + tex, lw, lh := tr(key, fallback, 18, color.RGBA{255, 255, 255, 255}) + return ui.Button{X: 113.5, Y: y, W: 200, H: 20, Tex: tex, LabelW: lw, LabelH: lh, Action: action} + } + title, tw, th := tr("deathScreen.title", "你死了!", 32, color.RGBA{255, 80, 80, 255}) + d := &ui.DeathScreen{ + PauseScreen: ui.PauseScreen{ + TitleTex: title, TitleW: tw, TitleH: th, + BtnTex: btnTex, BtnHover: btnHover, + Buttons: []ui.Button{ + mkBtn("deathScreen.respawn", "重生", "respawn", 100), + mkBtn("deathScreen.titleScreen", "返回标题", "title", 124), + }, + }, + } + return d +} + +func buildSelectWorldScreen(r *ui.Renderer, lang *assets.Lang, btnTex, btnHover uint32) *ui.SelectWorldScreen { + text := func(s string, size float64, c color.RGBA) (uint32, float32, float32) { + cjk := loadCJK(size) + if cjk == nil || s == "" { + return 0, 0, 0 + } + img := cjk.Draw(s, c) + return r.UploadImage(img), float32(img.Bounds().Dx()), float32(img.Bounds().Dy()) + } + tr := func(key, fallback string, size float64, c color.RGBA) (uint32, float32, float32) { + s := fallback + if lang != nil && lang.Get(key) != key { + s = lang.Get(key) + } + return text(s, size, c) + } + white := color.RGBA{255, 255, 255, 255} + mk := func(key, fallback, action string, x, y float32) ui.Button { + tex, lw, lh := tr(key, fallback, 16, white) + return ui.Button{X: x, Y: y, W: 148, H: 20, Tex: tex, LabelW: lw, LabelH: lh, Action: action} + } + s := &ui.SelectWorldScreen{BtnTex: btnTex, BtnHover: btnHover, Selected: -1} + s.TitleTex, s.TitleW, s.TitleH = tr("selectWorld.title", "选择世界", 32, white) + s.EmptyTex, s.EmptyW, s.EmptyH = tr("selectWorld.empty", "还没有世界", 18, white) + s.ConfirmQTex, s.ConfirmQW, s.ConfirmQH = tr("selectWorld.deleteQuestion", "确定要删除这个世界吗?", 18, white) + s.ConfirmTex, s.ConfirmW, s.ConfirmH = tr("selectWorld.deleteWarning", "将永远消失!(很长一段时间!)", 16, color.RGBA{255, 85, 85, 255}) + s.Buttons = []ui.Button{ + mk("selectWorld.select", "进入选中的世界", "play", 63, 186), + mk("selectWorld.create", "创建新的世界", "create", 215, 186), + mk("selectWorld.delete", "删除", "delete", 63, 210), + mk("gui.cancel", "取消", "cancel", 215, 210), + } + return s +} + +func buildCreateWorldScreen(r *ui.Renderer, lang *assets.Lang, btnTex, btnHover uint32) *ui.CreateWorldScreen { + text := func(s string, size float64, c color.RGBA) (uint32, float32, float32) { + cjk := loadCJK(size) + if cjk == nil || s == "" { + return 0, 0, 0 + } + img := cjk.Draw(s, c) + return r.UploadImage(img), float32(img.Bounds().Dx()), float32(img.Bounds().Dy()) + } + tr := func(key, fallback string, size float64, c color.RGBA) (uint32, float32, float32) { + s := fallback + if lang != nil && lang.Get(key) != key { + s = lang.Get(key) + } + return text(s, size, c) + } + white := color.RGBA{255, 255, 255, 255} + mk := func(key, fallback, action string, x, y float32) ui.Button { + tex, lw, lh := tr(key, fallback, 16, white) + return ui.Button{X: x, Y: y, W: 148, H: 20, Tex: tex, LabelW: lw, LabelH: lh, Action: action} + } + s := &ui.CreateWorldScreen{BtnTex: btnTex, BtnHover: btnHover} + s.TitleTex, s.TitleW, s.TitleH = tr("createWorld.title", "创建新的世界", 32, white) + s.NameLblTex, s.NameLblW, s.NameLblH = tr("createWorld.worldName", "世界名称", 16, white) + s.SeedLblTex, s.SeedLblW, s.SeedLblH = tr("createWorld.seed", "世界种子", 16, white) + s.Buttons = []ui.Button{ + mk("createWorld.create", "创建新的世界", "create", 63, 186), + mk("gui.cancel", "取消", "cancel", 215, 186), + } + return s +} + // buildOptionsScreen 构建设置界面(灵敏度/音量滑条、种子显示、按键教程列表)。 func buildOptionsScreen(r *ui.Renderer, lang *assets.Lang, btnTex, btnHover uint32, seed int64) *ui.OptionsScreen { text := func(s string, size float64, c color.RGBA) (uint32, float32, float32) { @@ -155,8 +258,8 @@ func buildOptionsScreen(r *ui.Renderer, lang *assets.Lang, btnTex, btnHover uint "按键教程:\n"+ "W/A/S/D 移动 空格 跳跃 Shift 潜行 Ctrl 冲刺\n"+ "鼠标左键 攻击/破坏 鼠标右键 放置/使用\n"+ - "E 打开背包 Q 丢弃物品 1-9/滚轮 切换快捷栏\n"+ - "Esc 暂停/返回", + "E 打开背包 Q 丢弃物品 1-9/滚轮 切换快捷栏(上滚左、下滚右)\n"+ + "F11 / Alt+Enter 全屏 Esc 暂停/返回", 16, white) s.DoneLabelTex, s.DoneLabelW, s.DoneLabelH = tr("options.done", "完成", 16, white) return s diff --git a/cmd/server/main.go b/cmd/server/main.go index 6bdd91f5..3f59cede 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -29,16 +29,17 @@ import ( // serverApp 服务器应用:世界 + 网络 + 定时存档 + Mod。 type serverApp struct { - cfg *config.ServerConfig - log *logx.Logger - reg *block.Registry - world *world.World - net *netserver.Server - hub *mpsync.Hub - modReg *mods.Registry - luaMods []*mods.LuaMod - seed int64 - tickCnt uint64 + cfg *config.ServerConfig + log *logx.Logger + reg *block.Registry + world *world.World + net *netserver.Server + hub *mpsync.Hub + modReg *mods.Registry + luaMods []*mods.LuaMod + seed int64 + tickCnt uint64 + savePool *save.SavePool } // ---- netserver.Handler 实现(多人同步.md §2 权威链路入口)---- @@ -76,15 +77,24 @@ func (s *serverApp) Tick(dt float64) { s.tickCnt++ // 区块调度(以出生点为中心;后续按玩家位置) s.world.Update(8, 64, 8, 8*time.Millisecond) + regionDir := paths.Join("worlds", s.cfg.World.Name, "region") + for _, c := range s.world.DrainUnloaded() { + if c.SaveDirty.Load() && s.savePool != nil { + s.savePool.Enqueue(save.ChunkJob{RegionDir: regionDir, CX: c.CX, CZ: c.CZ, Data: save.EncodeChunk(c)}) + c.SaveDirty.Store(false) + } + } // Mod 事件:每秒派发 on_tick(Mod开发指南.md §4.3) if s.tickCnt%20 == 0 { for _, m := range s.luaMods { _ = m.DispatchEvent("tick") } } - // 定时存档:30s(存档与持久化.md §2) + // 定时存档:30s 入队(存档与持久化.md §4:tick 内不 fsync) if s.tickCnt%(20*30) == 0 { - if err := save.SaveWorld(s.world, paths.Join("worlds"), s.cfg.World.Name, s.seed, int64(s.tickCnt)); err != nil { + if err := save.EnqueueDirty(s.world, s.savePool, regionDir); err != nil { + s.log.Errorf("定时存档失败: %v", err) + } else if err := save.WriteLevel(paths.Join("worlds"), s.cfg.World.Name, save.LevelData{Version: 1, Seed: s.seed, Time: int64(s.tickCnt)}); err != nil { s.log.Errorf("定时存档失败: %v", err) } else { s.log.Infof("已存档(tick %d)", s.tickCnt) @@ -135,7 +145,8 @@ func main() { log.Infof("新建世界 %q", cfg.World.Name) } - app := &serverApp{cfg: cfg, log: log, reg: reg, world: w, seed: seed} + pool := save.NewSavePool(log) + app := &serverApp{cfg: cfg, log: log, reg: reg, world: w, seed: seed, savePool: pool} app.hub = mpsync.NewHub(w, cfg.Server.ViewDistance) // Mod 加载(设计.md §3.3:扫描 mods/,type=mod 且有入口脚本的执行;热更新.md 由注册表支持) @@ -172,10 +183,14 @@ func main() { engine.Run(ctx, app, engine.NoopRenderer{}) - // 关服前全量存档(存档与持久化.md §2 关服 flush) - if err := save.SaveWorld(w, paths.Join("worlds"), cfg.World.Name, seed, int64(app.tickCnt)); err != nil { + // 关服前全量存档 + drain 保存池(存档与持久化.md §4) + if err := save.EnqueueDirty(w, pool, paths.Join("worlds", cfg.World.Name, "region")); err != nil { log.Errorf("关服存档失败: %v", err) } + if err := save.WriteLevel(paths.Join("worlds"), cfg.World.Name, save.LevelData{Version: 1, Seed: seed, Time: int64(app.tickCnt)}); err != nil { + log.Errorf("关服存档失败: %v", err) + } + pool.Close() log.Infof("服务器已退出") } diff --git a/go.mod b/go.mod index ea6da824..5c240f7b 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,8 @@ require ( require ( github.com/ebitengine/purego v0.9.0 // indirect github.com/hajimehoshi/oto v0.7.1 // indirect + github.com/jfreymuth/oggvorbis v1.0.5 // indirect + github.com/jfreymuth/vorbis v1.0.2 // indirect github.com/pkg/errors v0.9.1 // indirect golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect diff --git a/go.sum b/go.sum index 7a00420e..ec8eae07 100644 --- a/go.sum +++ b/go.sum @@ -24,7 +24,11 @@ github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6Ee github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= +github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= +github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= +github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= +github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= diff --git a/internal/atlas/atlas.go b/internal/atlas/atlas.go index ddb417fc..6e5f01ac 100644 --- a/internal/atlas/atlas.go +++ b/internal/atlas/atlas.go @@ -7,15 +7,26 @@ package atlas import ( "fmt" "image" + "image/color" "image/draw" "image/png" "os" + "strings" ) // Build 打包纹理键列表为图集。 // resolve:纹理键 → PNG 文件路径;缺失纹理跳过(键不进入 uv 表,运行时用占位纹理)。 // 尺寸取 2 的次幂(mipmap 兼容:非 2 次幂图集在部分驱动上 GenerateMipmap 失败会致纹理不完整)。 func Build(resolve func(key string) (string, bool), keys []string, tileSize int) (image.Image, map[string]image.Rectangle, error) { + return build(resolve, keys, tileSize, false) +} + +// BuildFill 与 Build 相同,但缺失键写入品红占位(物品图标漏图排查,UI还原.md §7)。 +func BuildFill(resolve func(key string) (string, bool), keys []string, tileSize int) (image.Image, map[string]image.Rectangle, error) { + return build(resolve, keys, tileSize, true) +} + +func build(resolve func(key string) (string, bool), keys []string, tileSize int, fillMissing bool) (image.Image, map[string]image.Rectangle, error) { if len(keys) == 0 { return nil, nil, fmt.Errorf("atlas.Build: 纹理列表为空") } @@ -34,20 +45,30 @@ func Build(resolve func(key string) (string, bool), keys []string, tileSize int) uv := make(map[string]image.Rectangle, len(keys)) for i, key := range keys { - path, ok := resolve(key) - if !ok { - continue // 缺失纹理:跳过(占位) - } - tile, err := loadPNG(path) - if err != nil { - return nil, nil, fmt.Errorf("atlas.Build %s: %w", key, err) - } x := (i % cols) * tileSize y := (i / cols) * tileSize r := image.Rect(x, y, x+tileSize, y+tileSize) - // 最近邻缩放(非标准尺寸纹理保持像素风) - scaled := scaleNearest(tile, tileSize) - draw.Draw(img, r, scaled, image.Point{}, draw.Src) + path, ok := resolve(key) + var tile image.Image + if !ok { + if !fillMissing && key != "entity/missing" { + continue // 缺失纹理:跳过,调用方按候选键回退 + } + tile = missingTile(tileSize) + } else { + loaded, err := loadPNG(path) + if err != nil { + return nil, nil, fmt.Errorf("atlas.Build %s: %w", key, err) + } + tile = loaded + } + var fitted image.Image + if strings.HasPrefix(key, "entity/") { + fitted = letterbox(tile, tileSize) // 实体皮肤:原尺寸贴左上,禁止拉伸 + } else { + fitted = scaleNearest(tile, tileSize) + } + draw.Draw(img, r, fitted, image.Point{}, draw.Src) uv[key] = r } return img, uv, nil @@ -92,3 +113,50 @@ func scaleNearest(src image.Image, size int) image.Image { } return dst } + +// letterbox 把源图按原像素贴到 size×size 左上角,多余区域透明。源图更大时等比缩小以放入。 +func letterbox(src image.Image, size int) image.Image { + sb := src.Bounds() + w, h := sb.Dx(), sb.Dy() + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + dw, dh := w, h + if w > size || h > size { + if w > h { + dw, dh = size, h*size/w + } else { + dh, dw = size, w*size/h + } + src = scaleNearest(src, max(dw, dh)) + sb = src.Bounds() + } + draw.Draw(dst, image.Rect(0, 0, dw, dh), src, sb.Min, draw.Src) + return dst +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// missingTile 品红/黑棋盘占位(缺实体贴图时仍能看见模型)。 +func missingTile(size int) image.Image { + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + mag := color.RGBA{255, 0, 255, 255} + blk := color.RGBA{0, 0, 0, 255} + cell := size / 8 + if cell < 1 { + cell = 1 + } + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if ((x/cell)+(y/cell))%2 == 0 { + dst.Set(x, y, mag) + } else { + dst.Set(x, y, blk) + } + } + } + return dst +} diff --git a/internal/atlas/atlas_test.go b/internal/atlas/atlas_test.go index a521bfaf..67e33358 100644 --- a/internal/atlas/atlas_test.go +++ b/internal/atlas/atlas_test.go @@ -2,6 +2,7 @@ package atlas import ( "image" + "image/color" "path/filepath" "testing" ) @@ -49,7 +50,24 @@ func TestBuild(t *testing.T) { } } -// TestScaleNearest 验证最近邻缩放尺寸。 +func TestLetterboxNoStretch(t *testing.T) { + src := image.NewRGBA(image.Rect(0, 0, 64, 32)) + src.Set(0, 0, image.NewRGBA(image.Rect(0, 0, 1, 1)).At(0, 0)) + for x := 0; x < 64; x++ { + src.Set(x, 0, color.RGBA{255, 0, 0, 255}) + src.Set(x, 31, color.RGBA{0, 255, 0, 255}) + } + dst := letterbox(src, 64) + db := dst.Bounds() + if db.Dx() != 64 || db.Dy() != 64 { + t.Fatalf("letterbox 尺寸期望 64×64,实际 %dx%d", db.Dx(), db.Dy()) + } + _, _, _, a := dst.At(0, 40).RGBA() + if a != 0 { + t.Fatal("64×32 皮肤下半应保持透明(未拉伸)") + } +} + func TestScaleNearest(t *testing.T) { src := image.NewRGBA(image.Rect(0, 0, 32, 32)) dst := scaleNearest(src, 16) diff --git a/internal/audio/audio.go b/internal/audio/audio.go index ec69195f..c8c2b007 100644 --- a/internal/audio/audio.go +++ b/internal/audio/audio.go @@ -6,6 +6,8 @@ package audio import ( "math" + "math/rand" + "strings" "sync" "time" @@ -16,7 +18,12 @@ import ( ) // 声源上限(音频.md §3:同时播放 ≤64,超出按最远淘汰)。 -const maxSources = 64 +const ( + maxSources = 64 + defaultAtten = 16.0 + captionTTL = 3 * time.Second + maxCaptions = 4 +) // 音量分类(音频.md §4)。 type category uint8 @@ -29,13 +36,18 @@ const ( // Manager 音频管理器(单例)。 type Manager struct { - mu sync.Mutex - enabled bool - ctx *oto.Context - master float64 - vols [3]float64 // sfx / music / ambient - sources []*source - listener [3]float64 // 玩家位置 + mu sync.Mutex + enabled bool + ctx *oto.Context + master float64 + vols [3]float64 // sfx / music / ambient + sources []*source + listener [3]float64 // 玩家位置 + listenerYaw float64 // 水平朝向(弧度,yaw=0 朝 -Z) + events map[string]*resolvedEvent + oggCache map[string]*beep.Buffer + captions []Caption + rng *rand.Rand } // source 一个正在播放的声源。 @@ -50,7 +62,7 @@ type source struct { // New 创建音频管理器(找不到音频设备时降级为静默——服务器/CI 安全)。 func New() *Manager { - m := &Manager{master: 1.0, vols: [3]float64{1, 1, 1}} + m := &Manager{master: 1.0, vols: [3]float64{1, 1, 1}, rng: rand.New(rand.NewSource(time.Now().UnixNano()))} const ( sampleRate = 44100 bufferSize = 100 * time.Millisecond @@ -69,10 +81,11 @@ func New() *Manager { return m } -// SetListener 更新玩家位置(每帧/tick)。 -func (m *Manager) SetListener(x, y, z float64) { +// SetListener 更新听者位置与朝向(每帧/tick;音频.md §3)。 +func (m *Manager) SetListener(x, y, z, yaw float64) { m.mu.Lock() m.listener = [3]float64{x, y, z} + m.listenerYaw = yaw m.mu.Unlock() } @@ -87,15 +100,32 @@ func (m *Manager) SetVolumes(master, sfx, music, ambient float64) { func (m *Manager) Play(key string, x, y, z float64) { m.mu.Lock() defer m.mu.Unlock() + dist := math.Sqrt((x-m.listener[0])*(x-m.listener[0]) + (y-m.listener[1])*(y-m.listener[1]) + (z-m.listener[2])*(z-m.listener[2])) + if attenuation(dist, defaultAtten) <= 0 { + return + } + m.pushCaption(key, m.subtitleOf(key)) if !m.enabled { return } - dist := math.Sqrt((x-m.listener[0])*(x-m.listener[0]) + (y-m.listener[1])*(y-m.listener[1]) + (z-m.listener[2])*(z-m.listener[2])) - gain := attenuation(dist) - if gain <= 0.01 { - return // 超出衰减范围:不发声 + pk := m.pick(key, 0) + params := PlayParams(key) + atten := defaultAtten + fileVol, filePitch := 1.0, 1.0 + var streamer beep.Streamer + if pk.ok { + streamer = pk.streamer + fileVol, filePitch = pk.volume, pk.pitch + if pk.atten > 0 { + atten = pk.atten + } + } else { + streamer = synthFor(key) + } + gain := params.Volume * fileVol * attenuation(dist, atten) * m.master * m.vols[catSFX] + if gain <= 0.01 { + return } - // 声源池:满员时淘汰最远(音频.md §3) if len(m.sources) >= maxSources { worst := -1 worstDist := -1.0 @@ -111,20 +141,75 @@ func (m *Manager) Play(key string, x, y, z float64) { m.sources = append(m.sources[:worst], m.sources[worst+1:]...) } } - // 程序化合成:按音效键选择波形(无音频文件的占位方案,音频.md §6) - streamer := synthFor(key) + streamer = pitchResample(params.Pitch*filePitch, streamer) + pan := stereoPan(x-m.listener[0], y-m.listener[1], z-m.listener[2], m.listenerYaw) + streamer = &panStreamer{Streamer: streamer, l: (1 - pan) / 2, r: (1 + pan) / 2} ctrl := &beep.Ctrl{Streamer: streamer, Paused: false} - vol := &effects.Volume{Streamer: ctrl, Base: 2, Volume: math.Log2(0.1+gain)*m.master*m.vols[catSFX] + 1} + vol := &effects.Volume{Streamer: ctrl, Base: 2, Volume: math.Log2(gain)} speaker.Play(vol) m.sources = append(m.sources, &source{key: key, pos: [3]float64{x, y, z}, dist: dist, ctrl: ctrl, start: time.Now()}) } -// attenuation 距离衰减:线性,maxDist 20(音频.md §3)。 -func attenuation(dist float64) float64 { - const maxDist = 20.0 +// PlayParams 按事件后缀套 Java 调用点音量/音调(音频.md §6.1)。 +func PlayParams(key string) struct{ Volume, Pitch float64 } { + switch { + case strings.HasSuffix(key, ".hit"): + return struct{ Volume, Pitch float64 }{0.25, 0.5} + case strings.HasSuffix(key, ".break"), strings.HasSuffix(key, ".place"): + return struct{ Volume, Pitch float64 }{1.0, 0.8} + case strings.HasSuffix(key, ".step"): + return struct{ Volume, Pitch float64 }{0.15, 1.0} + case strings.Contains(key, ".ambient"), strings.Contains(key, ".hurt"), strings.Contains(key, ".death"): + r1, r2 := rand.Float64(), rand.Float64() + return struct{ Volume, Pitch float64 }{1.0, (r1-r2)*0.2 + 1} + default: + return struct{ Volume, Pitch float64 }{1.0, 1.0} + } +} + +// stereoPan 朝向左右平移:左负右正,听者自身 dist≈0 → 0(音频.md §3,非 HRTF)。 +func stereoPan(dx, dy, dz, yaw float64) float64 { + dist := math.Sqrt(dx*dx + dy*dy + dz*dz) + if dist < 0.01 { + return 0 + } + localX := dx*math.Cos(yaw) - dz*math.Sin(yaw) + p := localX / dist + if p < -1 { + return -1 + } + if p > 1 { + return 1 + } + return p +} + +// panStreamer 按左右增益平衡立体声。 +type panStreamer struct { + beep.Streamer + l, r float64 +} + +func (p *panStreamer) Stream(samples [][2]float64) (n int, ok bool) { + n, ok = p.Streamer.Stream(samples) + for i := 0; i < n; i++ { + samples[i][0] *= p.l + samples[i][1] *= p.r + } + return +} + +// attenuation 线性衰减,默认 16 格(音频.md §3)。 +func attenuation(dist, maxDist float64) float64 { + if maxDist <= 0 { + maxDist = defaultAtten + } if dist >= maxDist { return 0 } + if dist <= 0 { + return 1 + } return 1 - dist/maxDist } @@ -141,11 +226,16 @@ func (m *Manager) Close() { // synthFor 按音效键生成占位波形(后续替换为真实音效文件)。 func synthFor(key string) beep.Streamer { - switch key { - case "block.stone.break", "block.grass.break": + switch { + case strings.Contains(key, ".break") || strings.Contains(key, ".hit"): return noiseBurst(0.12) - case "block.place", "step": + case strings.Contains(key, ".step") || strings.Contains(key, ".place") || + strings.Contains(key, "swim") || strings.Contains(key, "splash"): return sineBurst(220, 0.06) + case strings.Contains(key, "eat") || strings.Contains(key, "burp"): + return noiseBurst(0.08) + case strings.Contains(key, "hurt"): + return sineBurst(180, 0.08) default: return sineBurst(440, 0.1) } diff --git a/internal/audio/audio_test.go b/internal/audio/audio_test.go index 7ccf60e5..32fea56d 100644 --- a/internal/audio/audio_test.go +++ b/internal/audio/audio_test.go @@ -1,24 +1,43 @@ package audio -import "testing" +import ( + "math" + "os" + "path/filepath" + "testing" +) -// TestAttenuation 距离衰减公式(音频.md §3)。 func TestAttenuation(t *testing.T) { - if a := attenuation(0); a != 1 { + if a := attenuation(0, 16); a != 1 { t.Fatalf("零距离衰减期望 1,实际 %v", a) } - if a := attenuation(10); a != 0.5 { - t.Fatalf("10 格衰减期望 0.5,实际 %v", a) + if a := attenuation(8, 16); a != 0.5 { + t.Fatalf("8 格衰减期望 0.5,实际 %v", a) } - if a := attenuation(20); a != 0 { - t.Fatalf("最大距离衰减期望 0,实际 %v", a) + if a := attenuation(16, 16); a != 0 { + t.Fatalf("16 格衰减期望 0,实际 %v", a) } - if a := attenuation(30); a != 0 { + if a := attenuation(30, 16); a != 0 { t.Fatalf("超距衰减期望 0,实际 %v", a) } } -// TestBurstStreamer 合成流:有限长度与数据填充。 +func TestPlayParams(t *testing.T) { + if p := PlayParams("block.stone.hit"); p.Volume != 0.25 || p.Pitch != 0.5 { + t.Fatalf("hit 参数 %+v", p) + } + if p := PlayParams("block.stone.break"); p.Volume != 1 || p.Pitch != 0.8 { + t.Fatalf("break 参数 %+v", p) + } + if p := PlayParams("block.grass.step"); p.Volume != 0.15 || p.Pitch != 1 { + t.Fatalf("step 参数 %+v", p) + } + p := PlayParams("entity.sheep.hurt") + if p.Volume != 1 || p.Pitch < 0.8 || p.Pitch > 1.2 { + t.Fatalf("hurt 音调应在 0.8–1.2,实际 %+v", p) + } +} + func TestBurstStreamer(t *testing.T) { b := sineBurst(440, 0.05) samples := make([][2]float64, 1024) @@ -38,13 +57,98 @@ func TestBurstStreamer(t *testing.T) { } } -// TestManagerSilent 无音频设备环境(CI/服务器)下降级为静默、不崩溃。 func TestManagerSilent(t *testing.T) { - m := New() // 可能无设备:必须不 panic + m := New() defer m.Close() m.SetVolumes(1, 1, 1, 1) - m.SetListener(0, 0, 0) + m.SetListener(0, 0, 0, 0) for i := 0; i < 100; i++ { - m.Play("block.stone.break", float64(i), 0, 0) // 池满淘汰也不崩 + m.Play("block.stone.break", float64(i), 0, 0) + } +} + +func TestLoadSoundsFields(t *testing.T) { + dir := t.TempDir() + ogg := filepath.Join(dir, "stone1.ogg") + if err := os.WriteFile(ogg, []byte("not-ogg"), 0o644); err != nil { + t.Fatal(err) + } + m := New() + defer m.Close() + js := []byte(`{ + "block.stone.break":{"sounds":[{"name":"dig/stone1","weight":2}],"subtitle":"subtitles.block.generic.break"}, + "block.alias":{"sounds":[{"name":"block.stone.break","type":"event"}]} + }`) + m.LoadSounds(js, func(name string) (string, bool) { + if name == "dig/stone1" { + return ogg, true + } + return "", false + }) + if m.EventCount() != 2 { + t.Fatalf("事件数=%d", m.EventCount()) + } + if !m.HasOgg("block.stone.break") || !m.HasOgg("block.alias") { + t.Fatal("应解析到 ogg 与 event 重定向") + } + if g := m.Subtitle("block.stone.break"); g != "subtitles.block.generic.break" { + t.Fatalf("subtitle=%q", g) + } +} + +func TestLoadSoundsMissingOgg(t *testing.T) { + m := New() + defer m.Close() + js := []byte(`{"block.stone.break":{"sounds":["dig/stone1"]}}`) + m.LoadSounds(js, func(string) (string, bool) { return "", false }) + if m.EventCount() != 0 { + t.Fatal("无 ogg 时事件表应为空(回退合成器)") + } + m.Play("block.grass.step", 0, 0, 0) +} + +func TestCaptionsEnqueue(t *testing.T) { + m := New() + defer m.Close() + m.SetListener(0, 0, 0, 0) + m.Play("entity.sheep.ambient", 1, 0, 0) + m.Play("entity.sheep.ambient", 1, 0, 0) + m.Play("block.stone.break", 2, 0, 0) + m.Play("block.stone.break", 40, 0, 0) // 超距 + got := m.Captions() + if len(got) != 2 { + t.Fatalf("字幕行数=%d %+v", len(got), got) + } +} + +func TestStereoPan(t *testing.T) { + if p := stereoPan(0, 0, -5, 0); math.Abs(p) > 1e-9 { + t.Fatalf("正前方 pan=%v", p) + } + if p := stereoPan(0, 0, 0, 0); p != 0 { + t.Fatalf("听者自身 pan=%v", p) + } + if p := stereoPan(5, 0, 0, 0); p < 0.99 { + t.Fatalf("右侧 pan=%v", p) + } + if p := stereoPan(-5, 0, 0, 0); p > -0.99 { + t.Fatalf("左侧 pan=%v", p) + } + // yaw=0 朝 -Z,源在 +X 应右声道更大 + l, r := (1-1.0)/2, (1+1.0)/2 + src := sineBurst(440, 0.02) + p := &panStreamer{Streamer: src, l: l, r: r} + samples := make([][2]float64, 64) + n, _ := p.Stream(samples) + if n == 0 { + t.Fatal("应有采样") + } + var sl, sr float64 + for i := 0; i < n; i++ { + sl += math.Abs(samples[i][0]) + sr += math.Abs(samples[i][1]) + } + if sr <= sl { + t.Fatalf("右侧声源应右声道更大 L=%v R=%v", sl, sr) } } diff --git a/internal/block/registry.go b/internal/block/registry.go index 576a38ce..38444d12 100644 --- a/internal/block/registry.go +++ b/internal/block/registry.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "os" + "strings" ) // State 运行时方块状态:ID(低 16 位)+ meta(高 8 位)。 @@ -40,12 +41,122 @@ type Def struct { Translucent bool `json:"translucent"` Fluid bool `json:"fluid"` RequiredTier int `json:"required_tier"` - Drops string `json:"drops"` - NoDrop bool `json:"no_drop"` // 破坏不掉落(如树叶) + SoundGroup string `json:"sound_group"` + Drops DropList `json:"drops"` + NoDrop bool `json:"no_drop"` // 破坏不掉落(流体/基岩/玻璃) Textures map[string]string `json:"textures"` Tint string `json:"tint"` // 植被染色表:grass/foliage(渲染.md §4.1) } +// DropRule 一条掉落规则(挖矿与矿物.md §4)。 +type DropRule struct { + Item string `json:"item"` + Min int `json:"min"` + Max int `json:"max"` + Chance float64 `json:"chance"` // 0 表示 1;否则 0–1 + Tool string `json:"tool"` + RequiredTier int `json:"required_tier"` + OrElse string `json:"or_else"` +} + +// DropList 支持 JSON 字符串(旧式 "cobblestone")或规则数组。 +type DropList []DropRule + +// UnmarshalJSON 兼容 "drops": "dirt" 与 "drops": [{...}]。 +func (d *DropList) UnmarshalJSON(b []byte) error { + if len(b) == 0 || string(b) == "null" { + return nil + } + if b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *d = DropList{{Item: s, Min: 1, Max: 1}} + return nil + } + var arr []DropRule + if err := json.Unmarshal(b, &arr); err != nil { + return err + } + *d = arr + return nil +} + +// DropResult 一次掷骰结果。 +type DropResult struct { + Item string + Count int +} + +// RollDrops 按工具种类/等级掷掉落(创造模式由调用方跳过)。 +func (d Def) RollDrops(toolType string, toolTier int, rnd func() float64) []DropResult { + if d.NoDrop { + return nil + } + if rnd == nil { + rnd = func() float64 { return 1 } + } + if len(d.Drops) == 0 { + if d.Name == "" || d.Fluid { + return nil + } + return []DropResult{{Item: d.Name, Count: 1}} + } + var out []DropResult + for _, r := range d.Drops { + if r.Tool != "" && r.Tool != toolType { + continue + } + if r.RequiredTier > 0 && toolTier < r.RequiredTier { + continue + } + ch := r.Chance + if ch <= 0 { + ch = 1 + } + itemName := r.Item + if rnd() > ch { + if r.OrElse == "" { + continue + } + itemName = r.OrElse + } + if itemName == "" { + continue + } + minN, maxN := r.Min, r.Max + if minN <= 0 { + minN = 1 + } + if maxN < minN { + maxN = minN + } + n := minN + if maxN > minN { + span := maxN - minN + 1 + n = minN + int(rnd()*float64(span)) + if n > maxN { + n = maxN + } + } + out = append(out, DropResult{Item: itemName, Count: n}) + } + return out +} + +// BreakSound / PlaceSound / HitSound / StepSound 按 sound_group 发事件(音频.md §6.1)。 +func (d Def) soundGroup() string { + if d.SoundGroup != "" { + return d.SoundGroup + } + return "stone" +} +func (d Def) BreakSound() string { return "block." + d.soundGroup() + ".break" } +func (d Def) PlaceSound() string { return "block." + d.soundGroup() + ".place" } +func (d Def) HitSound() string { return "block." + d.soundGroup() + ".hit" } +func (d Def) StepSound() string { return "block." + d.soundGroup() + ".step" } + // Registry 方块注册表。 type Registry struct { byID []Def @@ -163,8 +274,17 @@ func (r *Registry) Texture(s State, face string) string { } // TintTextureKeys 返回「染色表名 → 涉及纹理键」:图集构建后按染色表给这些 -// 纹理乘色(渲染.md §4.1 植被染色)。无染色标记的方块不出现。 +// 纹理乘色(渲染.md §4.1)。跳过被无 tint 方块共享的纹理(dirt)和已着色侧面。 func (r *Registry) TintTextureKeys() map[string][]string { + shared := make(map[string]struct{}) + for _, d := range r.byID { + if d.ID == 0 || d.Tint != "" { + continue + } + for _, k := range d.Textures { + shared[k] = struct{}{} + } + } out := make(map[string][]string) for _, d := range r.byID { if d.ID == 0 || d.Tint == "" { @@ -175,9 +295,25 @@ func (r *Registry) TintTextureKeys() map[string][]string { if _, dup := seen[k]; dup { continue } + if !tintableKey(d.Tint, k, shared) { + continue + } seen[k] = struct{}{} out[d.Tint] = append(out[d.Tint], k) } } return out } + +func tintableKey(tint, key string, shared map[string]struct{}) bool { + if _, ok := shared[key]; ok { + return false + } + if strings.Contains(key, "dirt") { + return false + } + if tint == "grass" && strings.Contains(key, "side") { + return false + } + return true +} diff --git a/internal/block/registry_test.go b/internal/block/registry_test.go index 806a0ff8..5d5c1db5 100644 --- a/internal/block/registry_test.go +++ b/internal/block/registry_test.go @@ -71,3 +71,73 @@ func TestTexture(t *testing.T) { t.Fatalf("草方块底面纹理期望 block/dirt,实际 %q", tex) } } + +func TestTintTextureKeysSkipDirt(t *testing.T) { + r, err := Load(blocksPath) + if err != nil { + t.Fatalf("加载注册表失败: %v", err) + } + tinted := r.TintTextureKeys() + for _, keys := range tinted { + for _, k := range keys { + if k == "block/dirt" || k == "block/grass_block_side" { + t.Fatalf("不应染色共享/侧面纹理 %s,实际表=%v", k, tinted) + } + } + } + grass := tinted["grass"] + found := false + for _, k := range grass { + if k == "block/grass_block_top" { + found = true + } + } + if !found { + t.Fatalf("草顶应染色,实际 %v", grass) + } +} + +func TestRollDropsVanilla(t *testing.T) { + r, err := Load(blocksPath) + if err != nil { + t.Fatalf("加载注册表失败: %v", err) + } + stone := r.Get(mustID(t, r, "stone")) + if got := stone.RollDrops("", 0, func() float64 { return 0 }); len(got) != 0 { + t.Fatalf("空手挖石头应无掉落,实际 %+v", got) + } + if got := stone.RollDrops("pickaxe", 1, func() float64 { return 0 }); len(got) != 1 || got[0].Item != "cobblestone" { + t.Fatalf("镐挖石头应掉圆石,实际 %+v", got) + } + grass := r.Get(mustID(t, r, "grass_block")) + if got := grass.RollDrops("", 0, func() float64 { return 0 }); len(got) != 1 || got[0].Item != "dirt" { + t.Fatalf("草应掉泥土,实际 %+v", got) + } + iron := r.Get(mustID(t, r, "iron_ore")) + if got := iron.RollDrops("pickaxe", 1, func() float64 { return 0 }); len(got) != 0 { + t.Fatalf("木镐挖铁矿应无掉落,实际 %+v", got) + } + if got := iron.RollDrops("pickaxe", 2, func() float64 { return 0 }); len(got) != 1 || got[0].Item != "raw_iron" { + t.Fatalf("石镐挖铁矿应掉粗铁,实际 %+v", got) + } + glass := r.Get(mustID(t, r, "glass")) + if got := glass.RollDrops("pickaxe", 4, func() float64 { return 0 }); len(got) != 0 { + t.Fatalf("玻璃不应掉落,实际 %+v", got) + } + gravel := r.Get(mustID(t, r, "gravel")) + if got := gravel.RollDrops("", 0, func() float64 { return 0.05 }); len(got) != 1 || got[0].Item != "flint" { + t.Fatalf("沙砾低随机应掉燧石,实际 %+v", got) + } + if got := gravel.RollDrops("", 0, func() float64 { return 0.5 }); len(got) != 1 || got[0].Item != "gravel" { + t.Fatalf("沙砾高随机应掉沙砾,实际 %+v", got) + } +} + +func mustID(t *testing.T, r *Registry, name string) uint16 { + t.Helper() + id, ok := r.ID(name) + if !ok { + t.Fatalf("方块 %s 未注册", name) + } + return id +} diff --git a/internal/blockentity/blockentity.go b/internal/blockentity/blockentity.go index bb00dd75..47193d77 100644 --- a/internal/blockentity/blockentity.go +++ b/internal/blockentity/blockentity.go @@ -45,7 +45,12 @@ const CookTime = 200 // FuelTicks 燃料燃烧时长表(tick,物品与背包.md §6)。 var FuelTicks = map[string]int{ - "coal": 1600, + "coal": 1600, + "oak_planks": 300, + "oak_log": 300, + "stick": 100, + "crafting_table": 300, + "chest": 300, } // New 创建方块实体(按类型分配槽位)。 @@ -118,7 +123,7 @@ func (e *Entity) CanInsert(slot int, s item.Stack, reg *item.Registry) bool { case SlotInput: return e.Slots[slot].Empty() || e.Slots[slot].Name == s.Name case SlotOutput: - return e.Slots[slot].Empty() || e.Slots[slot].Name == s.Name + return false } } return inventory.CanMerge(e.Slots[slot], s) || e.Slots[slot].Empty() @@ -159,6 +164,47 @@ func (e *Entity) Remove(slot, count int) item.Stack { return out } +// Snapshot 存档快照(存档格式.md §2 block_entities)。 +type Snapshot struct { + X, Y, Z int32 `json:"x"` + Kind string `json:"kind"` + Slots []item.Stack `json:"slots,omitempty"` + BurnTicks int `json:"burn_ticks,omitempty"` + BurnMaxTicks int `json:"burn_max,omitempty"` + CookTicks int `json:"cook_ticks,omitempty"` +} + +// Serialize 导出快照。 +func (e *Entity) Serialize() Snapshot { + if e == nil { + return Snapshot{} + } + kind := "chest" + if e.Kind == KindFurnace { + kind = "furnace" + } + slots := make([]item.Stack, len(e.Slots)) + copy(slots, e.Slots) + return Snapshot{ + X: e.X, Y: e.Y, Z: e.Z, Kind: kind, Slots: slots, + BurnTicks: e.BurnTicks, BurnMaxTicks: e.BurnMaxTicks, CookTicks: e.CookTicks, + } +} + +// Deserialize 从快照重建。 +func Deserialize(s Snapshot) *Entity { + kind := KindChest + if s.Kind == "furnace" { + kind = KindFurnace + } + e := New(kind, s.X, s.Y, s.Z) + for i := 0; i < len(s.Slots) && i < len(e.Slots); i++ { + e.Slots[i] = s.Slots[i] + } + e.BurnTicks, e.BurnMaxTicks, e.CookTicks = s.BurnTicks, s.BurnMaxTicks, s.CookTicks + return e +} + // String 描述(调试)。 func (e *Entity) String() string { return fmt.Sprintf("blockentity(%d @%d,%d,%d)", e.Kind, e.X, e.Y, e.Z) diff --git a/internal/blockentity/blockentity_test.go b/internal/blockentity/blockentity_test.go index 56bfd295..cb538ce2 100644 --- a/internal/blockentity/blockentity_test.go +++ b/internal/blockentity/blockentity_test.go @@ -90,4 +90,19 @@ func TestFurnaceSlotRules(t *testing.T) { if !f.CanInsert(SlotFuel, item.Stack{Name: "coal", Count: 1}, reg) { t.Fatal("燃料槽应接受煤炭") } + if !f.CanInsert(SlotFuel, item.Stack{Name: "oak_planks", Count: 1}, reg) { + t.Fatal("燃料槽应接受木板") + } + if f.CanInsert(SlotOutput, item.Stack{Name: "iron_ingot", Count: 1}, reg) { + t.Fatal("输出格不应放入") + } +} + +func TestSerializeRoundtrip(t *testing.T) { + e := NewChest(1, 2, 3) + e.Slots[0] = item.Stack{Name: "stick", Count: 7} + got := Deserialize(e.Serialize()) + if got.Kind != KindChest || got.Slots[0].Count != 7 { + t.Fatalf("箱子快照往返失败: %+v", got) + } } diff --git a/internal/entity/breeding.go b/internal/entity/breeding.go index 06a4e2db..6f756816 100644 --- a/internal/entity/breeding.go +++ b/internal/entity/breeding.go @@ -16,26 +16,44 @@ const ( // Animal 动物附加数据(动物体系.md §2)。 type Animal struct { + Species int // 0 牛 / 1 猪 / 2 羊 / 3 鸡(声音/外观/掉落/繁殖共用) BreedCooldown int // 繁殖冷却剩余 tick GrowthTicks int // 幼崽成长剩余(0 = 成年) - ParentA ID // 近亲繁殖记录(后期) + LoveTicks int // 喂食后进入繁殖模式剩余 tick + Sheared bool + ParentA ID // 近亲繁殖记录(后期) ParentB ID } +// AnimalSpecies 读取物种字段;无 Animal 数据时回退 ID%4(动物体系.md §2)。 +func AnimalSpecies(e *Entity) int { + if e == nil || e.Kind != KindAnimal { + return 0 + } + if d, ok := e.Data.(*Animal); ok { + return d.Species + } + return int(e.ID % 4) +} + // Breed 繁殖两只同种成年动物 → 幼崽(动物体系.md §3:冷却与上限控制)。 // 返回新幼崽;冷却未到/上限达到返回 nil。 func (m *Manager) Breed(a, b *Entity, w WorldView) *Entity { if a.Kind != KindAnimal || b.Kind != KindAnimal || a.Dead || b.Dead { return nil } + sa, sb := AnimalSpecies(a), AnimalSpecies(b) + if sa != sb { + return nil + } ad, ok1 := a.Data.(*Animal) bd, ok2 := b.Data.(*Animal) if !ok1 { - ad = &Animal{} + ad = &Animal{Species: sa} a.Data = ad } if !ok2 { - bd = &Animal{} + bd = &Animal{Species: sb} b.Data = bd } if ad.BreedCooldown > 0 || bd.BreedCooldown > 0 || ad.GrowthTicks > 0 || bd.GrowthTicks > 0 { @@ -48,7 +66,7 @@ func (m *Manager) Breed(a, b *Entity, w WorldView) *Entity { ad.BreedCooldown = breedCooldown bd.BreedCooldown = breedCooldown baby := m.Spawn(KindAnimal, a.Pos[0], a.Pos[1], a.Pos[2]) - baby.Data = &Animal{GrowthTicks: babyGrowTime, ParentA: a.ID, ParentB: b.ID} + baby.Data = &Animal{Species: sa, GrowthTicks: babyGrowTime, ParentA: a.ID, ParentB: b.ID} baby.Health = 10 // 幼崽血量 return baby } @@ -65,6 +83,9 @@ func TickAnimal(e *Entity) { if d.BreedCooldown > 0 { d.BreedCooldown-- } + if d.LoveTicks > 0 { + d.LoveTicks-- + } if d.GrowthTicks > 0 { d.GrowthTicks-- if d.GrowthTicks == 0 { @@ -73,6 +94,78 @@ func TickAnimal(e *Entity) { } } +const loveDuration = 30 * 20 // 喂食后 30 秒可配对 + +// Feed 喂食进入 love;幼崽/冷却中返回 false。 +func Feed(e *Entity) bool { + if e == nil || e.Kind != KindAnimal || e.Dead { + return false + } + d, ok := e.Data.(*Animal) + if !ok { + d = &Animal{Species: AnimalSpecies(e)} + e.Data = d + } + if d.GrowthTicks > 0 || d.BreedCooldown > 0 { + return false + } + d.LoveTicks = loveDuration + return true +} + +// LovePartner 附近同种 love 中的另一只成年动物。 +func (m *Manager) LovePartner(e *Entity) *Entity { + if e == nil { + return nil + } + sp := AnimalSpecies(e) + for _, o := range m.List() { + if o == e || o.Kind != KindAnimal || o.Dead { + continue + } + if AnimalSpecies(o) != sp { + continue + } + d, ok := o.Data.(*Animal) + if !ok || d.LoveTicks <= 0 || d.GrowthTicks > 0 { + continue + } + dx, dz := o.Pos[0]-e.Pos[0], o.Pos[2]-e.Pos[2] + if dx*dx+dz*dz < 8*8 { + return o + } + } + return nil +} + +// ShearSheep 剪羊毛:未剪过的羊掉 1–3 白羊毛。 +func ShearSheep(e *Entity, nWool int) bool { + if e == nil || e.Kind != KindAnimal || AnimalSpecies(e) != 2 { + return false + } + d, ok := e.Data.(*Animal) + if !ok { + d = &Animal{Species: 2} + e.Data = d + } + if d.Sheared { + return false + } + d.Sheared = true + if nWool < 1 { + nWool = 1 + } + if nWool > 3 { + nWool = 3 + } + return true +} + +// MilkCow 空桶挤牛奶(牛)。 +func MilkCow(e *Entity) bool { + return e != nil && e.Kind == KindAnimal && !e.Dead && AnimalSpecies(e) == 0 +} + // countNear 统计同种动物数量(以 a 所在区块计,简化按距离 16)。 func (m *Manager) countNear(a *Entity, _ int) int { n := 0 diff --git a/internal/entity/breeding_test.go b/internal/entity/breeding_test.go index 20429daf..f1cb22a7 100644 --- a/internal/entity/breeding_test.go +++ b/internal/entity/breeding_test.go @@ -10,15 +10,15 @@ func TestBreed(t *testing.T) { m := NewManager() w := flatWorld{} a := m.Spawn(KindAnimal, 0.5, 63, 0.5) - a.Data = &Animal{} + a.Data = &Animal{Species: 1} b := m.Spawn(KindAnimal, 1.5, 63, 0.5) - b.Data = &Animal{} + b.Data = &Animal{Species: 1} baby := m.Breed(a, b, w) if baby == nil { t.Fatal("应繁殖出幼崽") } - if d, ok := baby.Data.(*Animal); !ok || d.GrowthTicks == 0 { + if d, ok := baby.Data.(*Animal); !ok || d.GrowthTicks == 0 || d.Species != 1 { t.Fatalf("幼崽数据异常: %+v", baby.Data) } // 冷却中:拒绝 @@ -40,9 +40,9 @@ func TestBreedCap(t *testing.T) { m := NewManager() w := flatWorld{} a := m.Spawn(KindAnimal, 0.5, 63, 0.5) - a.Data = &Animal{} + a.Data = &Animal{Species: 0} b := m.Spawn(KindAnimal, 1.5, 63, 0.5) - b.Data = &Animal{} + b.Data = &Animal{Species: 0} for i := 0; i < 12; i++ { m.Spawn(KindAnimal, 2.5, 63, 2.5+float64(i)) } @@ -51,7 +51,64 @@ func TestBreedCap(t *testing.T) { } } +func TestAnimalSpeciesFallback(t *testing.T) { + e := &Entity{ID: 6, Kind: KindAnimal} + if AnimalSpecies(e) != 2 { + t.Fatalf("无 Data 应回退 ID%%4=2,实际 %d", AnimalSpecies(e)) + } + e.Data = &Animal{Species: 3} + if AnimalSpecies(e) != 3 { + t.Fatalf("应读 Species 字段,实际 %d", AnimalSpecies(e)) + } +} + +func TestBreedCrossSpecies(t *testing.T) { + m := NewManager() + w := flatWorld{} + a := m.Spawn(KindAnimal, 0.5, 63, 0.5) + a.Data = &Animal{Species: 0} + b := m.Spawn(KindAnimal, 1.5, 63, 0.5) + b.Data = &Animal{Species: 2} + if m.Breed(a, b, w) != nil { + t.Fatal("异种不应繁殖") + } +} + // TestLoadTrades 交易表解析与职业查询(村民系统.md §7)。 +func TestFeedAndLove(t *testing.T) { + e := &Entity{Kind: KindAnimal, Data: &Animal{Species: 0}} + if !Feed(e) { + t.Fatal("成年牛应能喂食") + } + if e.Data.(*Animal).LoveTicks <= 0 { + t.Fatal("喂食应进入 love") + } + baby := &Entity{Kind: KindAnimal, Data: &Animal{Species: 0, GrowthTicks: 10}} + if Feed(baby) { + t.Fatal("幼崽不应进入繁殖") + } +} + +func TestShearAndMilk(t *testing.T) { + sheep := &Entity{Kind: KindAnimal, Data: &Animal{Species: 2}} + if !ShearSheep(sheep, 2) { + t.Fatal("羊应能剪毛") + } + if !sheep.Data.(*Animal).Sheared { + t.Fatal("剪后应标记 Sheared") + } + if ShearSheep(sheep, 1) { + t.Fatal("已剪不应再剪") + } + cow := &Entity{Kind: KindAnimal, Data: &Animal{Species: 0}} + if !MilkCow(cow) { + t.Fatal("牛应能挤奶") + } + if MilkCow(sheep) { + t.Fatal("羊不能挤奶") + } +} + func TestLoadTrades(t *testing.T) { tbl, err := LoadTrades(filepath.Join("..", "..", "assets", "config", "villager_trades.json")) if err != nil { diff --git a/internal/entity/entity.go b/internal/entity/entity.go index cfa721df..dacf15f5 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -59,6 +59,10 @@ type Entity struct { // Data 类型附加数据(掉落物 = *Drop,村民 = *Villager) Data any + + // AmbientT / WalkDist 音效时钟(音频.md §6.2;由 Session 推进)。 + AmbientT int + WalkDist float64 } // 僵尸 AI 状态。 @@ -140,6 +144,7 @@ func (m *Manager) Tick(dt float64, w WorldView, playerPos [3]float64) { e.tickZombie(dt, w, playerPos) case KindAnimal: e.tickAnimal(dt, w) + TickAnimal(e) case KindVillager: e.tickVillager(dt, w) } @@ -217,7 +222,7 @@ func (e *Entity) tickZombie(dt float64, w WorldView, playerPos [3]float64) { speed := 3.0 e.Vel[0] = dx / dist * speed e.Vel[2] = dz / dist * speed - e.Yaw = math.Atan2(-dx, -dz) + e.Yaw = faceVelYaw(e.Vel[0], e.Vel[2]) } if dist > 24 { e.state = zIdle @@ -256,6 +261,9 @@ func (e *Entity) tickAnimal(dt float64, w WorldView) { } e.Vel[0] = e.wanderDir[0] * 1.2 e.Vel[2] = e.wanderDir[1] * 1.2 + if e.Vel[0] != 0 || e.Vel[2] != 0 { + e.Yaw = faceVelYaw(e.Vel[0], e.Vel[2]) + } _ = w } @@ -274,4 +282,12 @@ func (e *Entity) tickVillager(dt float64, w WorldView) { } e.Vel[0] = e.wanderDir[0] e.Vel[2] = e.wanderDir[1] + if e.Vel[0] != 0 || e.Vel[2] != 0 { + e.Yaw = faceVelYaw(e.Vel[0], e.Vel[2]) + } +} + +// faceVelYaw 盒模型 −Z 指向速度(渲染.md §5.3:atan2(vx,vz)+π)。 +func faceVelYaw(vx, vz float64) float64 { + return math.Atan2(vx, vz) + math.Pi } diff --git a/internal/entity/entity_test.go b/internal/entity/entity_test.go index c299dc96..832a9837 100644 --- a/internal/entity/entity_test.go +++ b/internal/entity/entity_test.go @@ -205,3 +205,11 @@ func TestEntityFreeze(t *testing.T) { t.Fatalf("未加载区块实体应冻结,实际 x=%v", v.Pos[0]) } } + +func TestFaceVelYaw(t *testing.T) { + // 速度 +Z → yaw=π,模型 −Z 指向世界 +Z(渲染.md §5.3) + y := faceVelYaw(0, 1) + if y < 3.14 || y > 3.15 { + t.Fatalf("朝 +Z 时期望 π,实际 %v", y) + } +} diff --git a/internal/game/game.go b/internal/game/game.go index 596979d9..0784a8bb 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -6,6 +6,7 @@ package game import ( "math" "math/rand" + "sort" "time" "mc/internal/block" @@ -17,6 +18,7 @@ import ( "mc/internal/physics" "mc/internal/raycast" "mc/internal/recipe" + "mc/internal/redstone" "mc/internal/world" ) @@ -45,42 +47,107 @@ type Session struct { breaking bool breakProg float64 - // 玩家生命(僵尸/摔落伤害后置接入,实体系统.md §6) - Health int + // 玩家生命 / 饥饿(实体系统.md §6) + Health int + Hunger int + Saturation float64 + Exhaustion float64 + starveAcc float64 + wasGround bool + Dead bool + DeathCause string + Spawn [3]float64 - // 创造模式(跳过进度直接破坏) + // 创造模式(跳过进度直接破坏、不掉落) Creative bool + eatLeft float64 + eatName string + eatAcc float64 + walkDist float64 + nextStep float64 + fallDist float64 + swimAcc float64 + wasInWater bool + drownAcc float64 + destroyTicks int + // 工作台状态(工作台与合成表.md §5:3×3 网格 + 输出格) craftOpen bool craftGrid [9]item.Stack + // 物品栏 2×2 合成(UI还原.md §3.3:会话临时网格,关闭退回) + invGrid [4]item.Stack + + // 容器(熔炉/箱子) + contKind int // 0 无 / 1 熔炉 / 2 箱子 + contPos [3]int32 + + Unlocked map[string]bool // 配方书解锁(结果物品名) + + Trades *entity.TradeTable + tradeOpen bool + tradeID entity.ID + + // Pickup 拾取成功回调(客户端飞入动画;nil 则忽略) + Pickup func(name string, x, y, z float64, slot int) + // Sound 音效回调(客户端注入,音频.md §2 事件驱动;nil 则静默) Sound func(key string, x, y, z float64) // 村民生成(村民系统.md §2:村庄中心 → 村民;已生成过的中心不再生成) villagerTimer float64 villageSpawn map[[2]int32]bool + + WorldSeed int64 + TickN int64 + rsQ *redstone.Queue + rsDirty bool + fluidQ [][3]int32 } // NewSession 创建会话。 func NewSession(w *world.World, reg *block.Registry, items *item.Registry, recipes *recipe.Registry, p *physics.Player, inv *inventory.Inventory, cam *camera.Camera) *Session { - return &Session{ + s := &Session{ World: w, Reg: reg, Items: items, Recipes: recipes, Player: p, Inv: inv, Cam: cam, - Health: 20, Entities: entity.NewManager(), + Health: 20, Hunger: 20, Saturation: 5, Entities: entity.NewManager(), BlockEntities: make(map[[3]int32]*blockentity.Entity), rng: rand.New(rand.NewSource(12345)), villageSpawn: make(map[[2]int32]bool), + Unlocked: make(map[string]bool), + rsQ: redstone.NewQueue(), } + if p != nil { + s.Spawn = p.Pos + } + return s } // PlayerHealth 玩家当前生命(HUD 用)。 func (s *Session) PlayerHealth() int { return s.Health } +// PlayerHunger 饥饿值 0–20(HUD 鸡腿)。 +func (s *Session) PlayerHunger() int { return s.Hunger } + +// PlayerAir 氧气剩余秒数(HUD 气泡)。 +func (s *Session) PlayerAir() float64 { return s.Player.Air } + // Tick 每 tick(20Hz)会话逻辑(架构.md §3.2)。 func (s *Session) Tick(dt float64) { - // 物理积分(速度由输入层写入 Player.Vel) + if s.Dead { + s.World.Update(s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2], 8*time.Millisecond) + s.Entities.Tick(dt, s.World, s.Player.Pos) + s.tickBlockEntities() + return + } + vy := s.Player.Vel[1] s.Player.Tick(s.World, dt) + s.tickFallDamage(dt, vy) + s.tickDrown(dt) + s.tickEat(dt) + s.tickHunger(dt) + s.tickFootsteps(dt) + s.tickEntitySounds(dt) // 区块调度(主线程预算 8ms,区块管理.md §4.3) s.World.Update(s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2], 8*time.Millisecond) // 破坏进度累积 @@ -89,6 +156,7 @@ func (s *Session) Tick(dt float64) { } // 实体 AI(实体系统.md §3 限频决策在内部处理) s.Entities.Tick(dt, s.World, s.Player.Pos) + s.tickVillagerBuild() // 方块实体(熔炉烧炼,物品与背包.md §6) s.tickBlockEntities() // 掉落物拾取(实体系统.md §5;大堆叠按背包栈上限拆分成多栈,放不下回吐地面) @@ -101,6 +169,7 @@ func (s *Session) Tick(dt float64) { s.spawnAnimals(dt) // 村庄村民生成(村民系统.md §2:已加载区块内村庄中心 → 3 名村民) s.spawnVillagers(dt) + s.tickWorldRules() // 僵尸近身伤害(僵尸与敌对生物.md §3:3 点/秒) s.applyZombieDamage(dt) // 相机跟随玩家眼睛 @@ -117,6 +186,9 @@ func (s *Session) Cast(maxDist float64) (raycast.Hit, bool) { // BeginBreak 按下左键:锁定破坏目标(进度从 0 开始)。 func (s *Session) BeginBreak() { + if s.Dead { + return + } h, ok := s.Cast(5) if !ok { s.EndBreak() @@ -125,9 +197,7 @@ func (s *Session) BeginBreak() { s.breakTarget = h s.breaking = true s.breakProg = 0 - if s.Sound != nil { - s.Sound("block.hit", float64(h.X)+0.5, float64(h.Y)+0.5, float64(h.Z)+0.5) - } + s.destroyTicks = 0 if s.Creative { s.BreakNow() } @@ -140,6 +210,24 @@ func (s *Session) EndBreak() { s.breakTarget = raycast.Hit{} } +// Breaking 是否正在破坏(输入层按住续挖用)。 +func (s *Session) Breaking() bool { return s.breaking } + +// ContinueBreak 按住左键:未在挖则开始;准星换块则重置(方块交互与动画.md §3)。 +func (s *Session) ContinueBreak() { + h, ok := s.Cast(5) + if !ok { + if s.breaking { + s.EndBreak() + } + return + } + if s.breaking && s.breakTarget.X == h.X && s.breakTarget.Y == h.Y && s.breakTarget.Z == h.Z { + return + } + s.BeginBreak() +} + // BreakProgress 当前破坏进度(0–1,UI 裂纹用)。 func (s *Session) BreakProgress() float64 { return s.breakProg } @@ -184,6 +272,9 @@ func (s *Session) AttackInRange(maxDist float64) (entity.ID, bool) { // Attack 攻击视线内最近的实体:伤害 + 击退 + 死亡掉落(动物体系.md §5、僵尸与敌对生物.md §3)。 // 命中返回 true(调用方不再开始破坏方块)。 func (s *Session) Attack() bool { + if s.Dead { + return false + } id, ok := s.AttackInRange(5) if !ok { return false @@ -194,16 +285,23 @@ func (s *Session) Attack() bool { } e.Health -= 2 e.HitFlash = 0.15 // 受击白闪(打击反馈,客户端渲染) + s.damageHeldTool(2) // 击退(沿视线水平方向) f := s.Cam.Forward() e.Vel[0] += f[0] * 4 e.Vel[2] += f[2] * 4 if s.Sound != nil { - s.Sound("entity.hit", e.Pos[0], e.Pos[1]+1, e.Pos[2]) + s.Sound("entity.player.attack.strong", s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2]) } if e.Health > 0 { + if s.Sound != nil { + s.Sound(hurtSound(e), e.Pos[0], e.Pos[1]+1, e.Pos[2]) + } return true } + if s.Sound != nil { + s.Sound(deathSound(e), e.Pos[0], e.Pos[1]+1, e.Pos[2]) + } e.Dead = true // 死亡掉落(与 vanilla 一致:动物掉肉,僵尸掉腐肉) loot := "" @@ -211,7 +309,7 @@ func (s *Session) Attack() bool { case entity.KindZombie: loot = "rotten_flesh" case entity.KindAnimal: - switch e.ID % 4 { + switch entity.AnimalSpecies(e) { case 0: loot = "beef" case 1: @@ -229,6 +327,14 @@ func (s *Session) Attack() bool { return true } +// SwingMiss 挥空:无实体且无准星方块时由输入层在按下边沿调用(音频.md §6.2)。 +func (s *Session) SwingMiss() { + if s.Sound == nil { + return + } + s.Sound("entity.player.attack.nodamage", s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2]) +} + // DropSelected 丢弃选中槽 1 个物品(UI与输入.md §5 Q 键;掉落在视线前方)。 func (s *Session) DropSelected() { st := s.Inv.Slots[s.Inv.Selected] @@ -259,7 +365,22 @@ func (s *Session) pickupDrop(d entity.Drop) { if chunk > limit { chunk = limit } + before := s.Inv.CountItem(d.Item) leftover := s.Inv.Add(item.Stack{Name: d.Item, Count: uint8(chunk)}) + if leftover.Empty() && s.Pickup != nil { + slot := 0 + for i, st := range s.Inv.Slots { + if i >= inventory.StorageSize { + break + } + if st.Name == d.Item { + slot = i + break + } + } + s.Pickup(d.Item, s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2], slot) + _ = before + } if !leftover.Empty() { // 背包满:剩余重新掉回地面(含未放入部分) s.Entities.SpawnDrop(d.Item, rem, s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2]) @@ -292,7 +413,12 @@ func (s *Session) spawnVillagers(dt float64) { for i := 0; i < 3; i++ { e := s.Entities.Spawn(entity.KindVillager, float64(center[0])+float64(i%2)*2, float64(y+1), float64(center[1])+float64(i/2)*2) - e.Data = &entity.Villager{} + v := &entity.Villager{Profession: uint8(i % 4)} + if v.Profession == 1 { + ox, oz := center[0]+int32(8+i*8), center[1]+int32(i*4) + v.BuildQueue = entity.Blueprint([3]int32{ox, y, oz}, uint64(center[0])*31+uint64(i)) + } + e.Data = v } if s.Sound != nil { s.Sound("entity.villager.ambient", float64(center[0]), float64(y+1), float64(center[1])) @@ -326,6 +452,10 @@ func (s *Session) accumulateBreak(dt float64) { s.EndBreak() // 目标已消失 return } + if s.Sound != nil && s.destroyTicks%4 == 0 { + s.Sound(def.HitSound(), float64(s.breakTarget.X)+0.5, float64(s.breakTarget.Y)+0.5, float64(s.breakTarget.Z)+0.5) + } + s.destroyTicks++ tool := s.Inv.SelectedStack() s.breakProg += s.breakSpeed(def, tool) * dt if s.breakProg >= 1.0 { @@ -339,20 +469,27 @@ func (s *Session) destroy(h raycast.Hit) { st := s.World.Block(h.X, h.Y, h.Z) def := s.Reg.Get(st.ID()) s.World.SetBlock(h.X, h.Y, h.Z, block.Air) + s.notifyNeighbors(h.X, h.Y, h.Z) // 方块实体随之销毁(物品与背包.md §6) delete(s.BlockEntities, [3]int32{h.X, h.Y, h.Z}) if s.Sound != nil { - s.Sound("block."+def.Name+".break", float64(h.X)+0.5, float64(h.Y)+0.5, float64(h.Z)+0.5) + s.Sound(def.BreakSound(), float64(h.X)+0.5, float64(h.Y)+0.5, float64(h.Z)+0.5) } - if def.NoDrop { + if !s.Creative { + s.damageHeldTool(1) + s.AddExhaustion(0.005) + } + if s.Creative || def.NoDrop { return } - drop := def.Drops - if drop == "" { - drop = def.Name + toolType, toolTier := "", 0 + if d, ok := s.Items.Get(s.Inv.SelectedStack().Name); ok { + toolType, toolTier = d.Tool, d.Tier } - if drop != "" { - s.Entities.SpawnDrop(drop, 1, float64(h.X)+0.5, float64(h.Y), float64(h.Z)+0.5) + for _, drop := range def.RollDrops(toolType, toolTier, s.rng.Float64) { + if drop.Count > 0 { + s.Entities.SpawnDrop(drop.Item, drop.Count, float64(h.X)+0.5, float64(h.Y), float64(h.Z)+0.5) + } } } @@ -367,6 +504,9 @@ func (s *Session) BreakNow() { // PlaceSelected 放置选中槽物品(右键,方块交互与动画.md §6)。 func (s *Session) PlaceSelected() { + if s.Dead { + return + } sel := s.Inv.SelectedStack() if sel.Empty() { return @@ -393,15 +533,22 @@ func (s *Session) PlaceSelected() { return } s.World.SetBlock(px, py, pz, block.NewState(bid, 0)) + s.notifyNeighbors(px, py, pz) if s.Sound != nil { - s.Sound("block.place", float64(px)+0.5, float64(py)+0.5, float64(pz)+0.5) + s.Sound(s.Reg.Get(bid).PlaceSound(), float64(px)+0.5, float64(py)+0.5, float64(pz)+0.5) } // 方块实体创建(物品与背包.md §6:熔炉) if d.Place == "furnace" { s.BlockEntities[[3]int32{px, py, pz}] = blockentity.NewFurnace(px, py, pz) } + if d.Place == "chest" { + s.BlockEntities[[3]int32{px, py, pz}] = blockentity.NewChest(px, py, pz) + } // 消耗一个物品 s.Inv.Remove(sel.Name, 1) + if sel.Name == "water_bucket" { + s.Inv.Add(item.Stack{Name: "bucket", Count: 1}) + } } // overlapsPlayer 放置位是否与玩家 AABB 重叠。 @@ -440,6 +587,135 @@ func (s *Session) TryOpenCrafting() bool { return true } +func (s *Session) blockID(name string) uint16 { + id, ok := s.Reg.ID(name) + if !ok { + return 0 + } + return id +} + +// TryOpenFurnace 右键熔炉。 +func (s *Session) TryOpenFurnace() bool { + h, ok := s.Cast(5) + if !ok || s.World.Block(h.X, h.Y, h.Z).ID() != s.blockID("furnace") { + return false + } + pos := [3]int32{h.X, h.Y, h.Z} + if s.BlockEntities[pos] == nil { + s.BlockEntities[pos] = blockentity.NewFurnace(h.X, h.Y, h.Z) + } + s.contKind, s.contPos = 1, pos + return true +} + +// TryOpenChest 右键箱子。 +func (s *Session) TryOpenChest() bool { + h, ok := s.Cast(5) + if !ok || s.World.Block(h.X, h.Y, h.Z).ID() != s.blockID("chest") { + return false + } + pos := [3]int32{h.X, h.Y, h.Z} + if s.BlockEntities[pos] == nil { + s.BlockEntities[pos] = blockentity.NewChest(h.X, h.Y, h.Z) + } + s.contKind, s.contPos = 2, pos + return true +} + +// ContainerOpen 打开的容器:1 熔炉 / 2 箱子 / 0 无。 +func (s *Session) ContainerOpen() int { return s.contKind } + +// ContainerEntity 当前打开的方块实体。 +func (s *Session) ContainerEntity() *blockentity.Entity { + if s.contKind == 0 { + return nil + } + return s.BlockEntities[s.contPos] +} + +// CloseContainer 关闭熔炉/箱子。 +func (s *Session) CloseContainer() { s.contKind = 0 } + +// ExportBlockEntities 存档用快照。 +func (s *Session) ExportBlockEntities() []blockentity.Snapshot { + out := make([]blockentity.Snapshot, 0, len(s.BlockEntities)) + for _, e := range s.BlockEntities { + out = append(out, e.Serialize()) + } + return out +} + +// ImportBlockEntities 读档。 +func (s *Session) ImportBlockEntities(list []blockentity.Snapshot) { + s.BlockEntities = make(map[[3]int32]*blockentity.Entity, len(list)) + for _, snap := range list { + e := blockentity.Deserialize(snap) + s.BlockEntities[[3]int32{e.X, e.Y, e.Z}] = e + } +} + +// UnlockRecipe 解锁配方(按产物名)。 +func (s *Session) UnlockRecipe(name string) { + if name == "" { + return + } + if s.Unlocked == nil { + s.Unlocked = make(map[string]bool) + } + s.Unlocked[name] = true +} + +// RecipeUnlocked 是否已解锁。 +func (s *Session) RecipeUnlocked(name string) bool { + return s.Unlocked != nil && s.Unlocked[name] +} + +// ExportUnlocked 存档用已解锁配方名。 +func (s *Session) ExportUnlocked() []string { + if len(s.Unlocked) == 0 { + return nil + } + out := make([]string, 0, len(s.Unlocked)) + for name, ok := range s.Unlocked { + if ok { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +// ImportUnlocked 读档。 +func (s *Session) ImportUnlocked(list []string) { + s.Unlocked = make(map[string]bool, len(list)) + for _, name := range list { + if name != "" { + s.Unlocked[name] = true + } + } +} + +// DiscoverCraftable 背包能做出的合成配方标为已解锁。 +func (s *Session) DiscoverCraftable() { + if s.Recipes == nil { + return + } + for _, rc := range s.Recipes.CraftingRecipes() { + need := recipe.NeedCounts(rc) + ok := true + for name, n := range need { + if s.Inv.CountItem(name) < n { + ok = false + break + } + } + if ok { + s.UnlockRecipe(rc.Result.Item) + } + } +} + // OpenCrafting 直接打开合成界面(开发测试/后续按键绑定用)。 func (s *Session) OpenCrafting() { s.craftOpen = true } @@ -454,27 +730,72 @@ func (s *Session) CraftSlot(i int) item.Stack { return s.craftGrid[i] } -// CraftingSwap 网格槽与手持栈交换/合并(同物品合并到上限),返回新的手持栈。 +// CraftGridPtr 3×3 网格槽指针(拖拽均分)。 +func (s *Session) CraftGridPtr(i int) *item.Stack { + if i < 0 || i >= 9 { + return nil + } + return &s.craftGrid[i] +} + +// InvCraftGridPtr 物品栏 2×2 槽指针。 +func (s *Session) InvCraftGridPtr(i int) *item.Stack { + if i < 0 || i >= 4 { + return nil + } + return &s.invGrid[i] +} + +// CraftingSwap 网格槽与手持栈左键交换/合并,返回新的手持栈。 func (s *Session) CraftingSwap(i int, held item.Stack) item.Stack { + return s.CraftingClick(i, held, false) +} + +// CraftingClick 网格槽左/右键(UI与输入.md §4)。 +func (s *Session) CraftingClick(i int, held item.Stack, right bool) item.Stack { if i < 0 || i >= 9 { return held } cur := s.craftGrid[i] - switch { - case held.Empty(): - s.craftGrid[i] = item.Stack{} - return cur - case cur.Empty(): - s.craftGrid[i] = held - return item.Stack{} - case cur.Name == held.Name: - merged, remain := inventory.Merge(held.Limit(s.Items), cur, held) - s.craftGrid[i] = merged - return remain - default: - s.craftGrid[i] = held - return cur + limit := held.Limit(s.Items) + if held.Empty() { + limit = cur.Limit(s.Items) } + var ns, nh item.Stack + if right { + ns, nh = inventory.ClickRight(cur, held, limit, true) + } else { + ns, nh = inventory.ClickLeft(cur, held, limit, true) + } + s.craftGrid[i] = ns + return nh +} + +// CraftingShiftGrid Shift+左键:网格 → 背包,返回是否移动。 +func (s *Session) CraftingShiftGrid(i int) bool { + if i < 0 || i >= 9 || s.craftGrid[i].Empty() { + return false + } + rem := s.Inv.ShiftIntoStorage(s.craftGrid[i]) + s.craftGrid[i] = rem + return rem.Empty() +} + +// CraftingShiftInv Shift+左键:背包槽 → 网格。 +func (s *Session) CraftingShiftInv(idx int) bool { + if idx < 0 || idx >= inventory.StorageSize || s.Inv.Slots[idx].Empty() { + return false + } + src := s.Inv.Slots[idx] + s.Inv.Slots[idx] = item.Stack{} + grid := s.craftGrid[:] + rem := inventory.ShiftIntoSlots(s.Items, grid, src) + copy(s.craftGrid[:], grid) + if !rem.Empty() { + s.Inv.Slots[idx] = rem + return !inventory.CanMerge(src, rem) || rem.Count != src.Count + } + return true } // craftResult 按网格匹配配方(有序优先,再无序),返回产物。 @@ -494,14 +815,22 @@ func (s *Session) craftResult() (item.Stack, bool) { } // 有序配方优先(避免无序规则误吞有序网格,工作台与合成表.md §4) if rc, ok := s.Recipes.MatchShaped(g); ok { - return item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))}, true + return s.stackFromRecipe(rc), true } if rc, ok := s.Recipes.MatchShapeless(counts); ok { - return item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))}, true + return s.stackFromRecipe(rc), true } return item.Stack{}, false } +func (s *Session) stackFromRecipe(rc *recipe.Recipe) item.Stack { + st := item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))} + if d, ok := s.Items.Get(st.Name); ok && d.Durability > 0 { + st.Durability = int32(d.Durability) + } + return st +} + // CraftingResult 当前网格的合成产物(无则 ok=false,UI 输出格用)。 func (s *Session) CraftingResult() (item.Stack, bool) { return s.craftResult() } @@ -522,9 +851,31 @@ func (s *Session) CraftingTakeResult() (item.Stack, bool) { s.craftGrid[i].Count-- } } + s.UnlockRecipe(res.Name) return res, true } +// CraftingTakeResultAll Shift 批量合成:产物进背包,直到材料不足或背包满(上限 64 次)。 +func (s *Session) CraftingTakeResultAll() int { + n := 0 + for n < inventory.MaxCraftBulk { + res, ok := s.craftResult() + if !ok || !s.Inv.Fits(res) { + break + } + taken, ok := s.CraftingTakeResult() + if !ok { + break + } + if rem := s.Inv.Add(taken); !rem.Empty() { + s.Entities.SpawnDrop(rem.Name, int(rem.Count), s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2]) + break + } + n++ + } + return n +} + // CloseCrafting 关闭合成界面:网格剩余材料退回背包(放不下掉落地面)。 func (s *Session) CloseCrafting() { if !s.craftOpen { @@ -542,6 +893,169 @@ func (s *Session) CloseCrafting() { s.craftOpen = false } +// InvCraftSlot 读物品栏 2×2 格。 +func (s *Session) InvCraftSlot(i int) item.Stack { + if i < 0 || i >= 4 { + return item.Stack{} + } + return s.invGrid[i] +} + +// InvCraftClick 物品栏 2×2 左/右键。 +func (s *Session) InvCraftClick(i int, held item.Stack, right bool) item.Stack { + if i < 0 || i >= 4 { + return held + } + cur := s.invGrid[i] + limit := held.Limit(s.Items) + if held.Empty() { + limit = cur.Limit(s.Items) + } + var ns, nh item.Stack + if right { + ns, nh = inventory.ClickRight(cur, held, limit, true) + } else { + ns, nh = inventory.ClickLeft(cur, held, limit, true) + } + s.invGrid[i] = ns + return nh +} + +// InvCraftShift 2×2 格 Shift → 背包。 +func (s *Session) InvCraftShift(i int) bool { + if i < 0 || i >= 4 || s.invGrid[i].Empty() { + return false + } + rem := s.Inv.ShiftIntoStorage(s.invGrid[i]) + s.invGrid[i] = rem + return rem.Empty() +} + +func (s *Session) invCraftResult() (item.Stack, bool) { + var g [3][3]string + counts := make(map[string]int) + for i := 0; i < 4; i++ { + st := s.invGrid[i] + if st.Empty() { + continue + } + g[i/2][i%2] = st.Name + counts[st.Name]++ + } + if len(counts) == 0 { + return item.Stack{}, false + } + if rc, ok := s.Recipes.MatchShaped(g); ok { + return s.stackFromRecipe(rc), true + } + if rc, ok := s.Recipes.MatchShapeless(counts); ok { + return s.stackFromRecipe(rc), true + } + return item.Stack{}, false +} + +// InvCraftResult 物品栏 2×2 当前产物。 +func (s *Session) InvCraftResult() (item.Stack, bool) { return s.invCraftResult() } + +// InvCraftTakeResult 物品栏 2×2 执行一次合成。 +func (s *Session) InvCraftTakeResult() (item.Stack, bool) { + res, ok := s.invCraftResult() + if !ok { + return item.Stack{}, false + } + for i := range s.invGrid { + if s.invGrid[i].Empty() { + continue + } + if s.invGrid[i].Count <= 1 { + s.invGrid[i] = item.Stack{} + } else { + s.invGrid[i].Count-- + } + } + s.UnlockRecipe(res.Name) + return res, true +} + +// InvCraftTakeResultAll 物品栏输出格 Shift 批量合成。 +func (s *Session) InvCraftTakeResultAll() int { + n := 0 + for n < inventory.MaxCraftBulk { + res, ok := s.invCraftResult() + if !ok || !s.Inv.Fits(res) { + break + } + taken, ok := s.InvCraftTakeResult() + if !ok { + break + } + if rem := s.Inv.Add(taken); !rem.Empty() { + s.Entities.SpawnDrop(rem.Name, int(rem.Count), s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2]) + break + } + n++ + } + return n +} + +// CloseInvCraft 关闭物品栏:2×2 余料退回背包。 +func (s *Session) CloseInvCraft() { + for i := range s.invGrid { + if s.invGrid[i].Empty() { + continue + } + if rem := s.Inv.Add(s.invGrid[i]); !rem.Empty() { + s.Entities.SpawnDrop(rem.Name, int(rem.Count), s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2]) + } + s.invGrid[i] = item.Stack{} + } +} + +// CraftingFillRecipe 从背包把配方填入 3×3 网格;材料不够则不改动。 +func (s *Session) CraftingFillRecipe(rc *recipe.Recipe) bool { + if rc == nil || (rc.Type != "crafting_shaped" && rc.Type != "crafting_shapeless") { + return false + } + need := recipe.NeedCounts(rc) + have := make(map[string]int, len(need)) + for name := range need { + have[name] = s.Inv.CountItem(name) + } + for _, st := range s.craftGrid { + if !st.Empty() { + have[st.Name] += int(st.Count) + } + } + for name, n := range need { + if have[name] < n { + return false + } + } + // 网格先退回背包 + for i := range s.craftGrid { + if s.craftGrid[i].Empty() { + continue + } + if rem := s.Inv.Add(s.craftGrid[i]); !rem.Empty() { + s.craftGrid[i] = rem + return false + } + s.craftGrid[i] = item.Stack{} + } + cells := recipe.PlaceCells(rc) + for i, name := range cells { + if name == "" { + continue + } + if !s.Inv.TakeOne(name) { + // 理论上已校验,失败则停 + return false + } + s.craftGrid[i] = item.Stack{Name: name, Count: 1} + } + return true +} + // tickBlockEntities 熔炉烧炼并产出(物品与背包.md §6)。 func (s *Session) tickBlockEntities() { for _, be := range s.BlockEntities { @@ -617,7 +1131,7 @@ func (s *Session) spawnAnimals(dt float64) { continue } e := s.Entities.Spawn(entity.KindAnimal, float64(x)+0.5, float64(y+1), float64(z)+0.5) - e.Data = &entity.Animal{} + e.Data = &entity.Animal{Species: s.rng.Intn(4)} return } } @@ -649,18 +1163,214 @@ func (s *Session) applyZombieDamage(dt float64) { s.Health -= 3 s.damageIn = 1.0 if s.Health <= 0 { - s.Health = 0 - s.respawn() + s.kill("被僵尸杀死了") } return } } } -// respawn 死亡重生:回出生点 + 满血(实体系统.md §6)。 -func (s *Session) respawn() { - s.Health = 20 - s.Player.Pos = [3]float64{8.5, 120, 8.5} - s.Player.Vel = [3]float64{} - s.damageIn = 2.0 +func speciesKey(e *entity.Entity) string { + switch e.Kind { + case entity.KindZombie: + return "zombie" + case entity.KindVillager: + return "villager" + case entity.KindAnimal: + switch entity.AnimalSpecies(e) { + case 0: + return "cow" + case 1: + return "pig" + case 2: + return "sheep" + default: + return "chicken" + } + default: + return "generic" + } +} + +func hurtSound(e *entity.Entity) string { + return "entity." + speciesKey(e) + ".hurt" +} + +func deathSound(e *entity.Entity) string { + return "entity." + speciesKey(e) + ".death" +} + +func ambientSound(e *entity.Entity) string { + return "entity." + speciesKey(e) + ".ambient" +} + +func (s *Session) tickEntitySounds(dt float64) { + if s.Sound == nil { + return + } + for _, e := range s.Entities.List() { + if e.Dead || e.Kind == entity.KindItemDrop { + continue + } + e.AmbientT++ + if s.rng.Intn(1000) < e.AmbientT { + e.AmbientT = -80 + s.Sound(ambientSound(e), e.Pos[0], e.Pos[1]+1, e.Pos[2]) + } + e.WalkDist += math.Hypot(e.Vel[0], e.Vel[2]) * dt * 0.6 + if e.WalkDist < 1 { + continue + } + e.WalkDist -= math.Floor(e.WalkDist) + bx := int32(math.Floor(e.Pos[0])) + by := int32(math.Floor(e.Pos[1] - 0.05)) + bz := int32(math.Floor(e.Pos[2])) + def := s.Reg.Get(s.World.Block(bx, by, bz).ID()) + if def.ID != 0 { + s.Sound(def.StepSound(), e.Pos[0], e.Pos[1], e.Pos[2]) + } + } +} + +func (s *Session) tickFallDamage(dt, vy float64) { + p := s.Player + if p.Flying || p.InWater { + s.fallDist = 0 + return + } + if !p.OnGround { + if vy < 0 { + s.fallDist += -vy * dt + } + return + } + dist := s.fallDist + s.fallDist = 0 + if dist < 3 { + return + } + dmg := 0 + if vy < -16 { + dmg = int((-vy - 16) / 4) + } + if dmg > 0 { + if s.Sound != nil { + s.Sound("entity.player.big_fall", p.Pos[0], p.Pos[1], p.Pos[2]) + } + s.Health -= dmg + if s.Sound != nil { + s.Sound("entity.player.hurt", p.Pos[0], p.Pos[1]+1, p.Pos[2]) + } + if s.Health <= 0 { + s.kill("摔死了") + } + return + } + if s.Sound != nil { + s.Sound("entity.player.small_fall", p.Pos[0], p.Pos[1], p.Pos[2]) + } +} + +func (s *Session) tickDrown(dt float64) { + if s.Player.EyeInWater && s.Player.Air <= 0 { + s.drownAcc += dt + if s.drownAcc >= 1 { + s.drownAcc = 0 + s.Health -= 4 + if s.Sound != nil { + s.Sound("entity.player.hurt", s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2]) + } + if s.Health <= 0 { + s.kill("淹死了") + } + } + } else { + s.drownAcc = 0 + } +} + +func (s *Session) tickFootsteps(dt float64) { + p := s.Player + if p.InWater { + if !s.wasInWater && s.Sound != nil { + s.Sound("entity.generic.splash", p.Pos[0], p.Pos[1], p.Pos[2]) + } + s.wasInWater = true + s.swimAcc += math.Hypot(p.Vel[0], p.Vel[2]) * dt + if s.swimAcc >= 0.8 { + s.swimAcc = 0 + if s.Sound != nil { + s.Sound("entity.generic.swim", p.Pos[0], p.Pos[1], p.Pos[2]) + } + } + return + } + s.wasInWater = false + if !p.OnGround { + return + } + s.walkDist += math.Hypot(p.Vel[0], p.Vel[2]) * dt * 0.6 + if s.walkDist < s.nextStep { + return + } + s.nextStep = math.Floor(s.walkDist) + 1 + bx := int32(math.Floor(p.Pos[0])) + by := int32(math.Floor(p.Pos[1] - 0.05)) + bz := int32(math.Floor(p.Pos[2])) + def := s.Reg.Get(s.World.Block(bx, by, bz).ID()) + if def.ID == 0 { + return + } + if s.Sound != nil { + s.Sound(def.StepSound(), p.Pos[0], p.Pos[1], p.Pos[2]) + } +} + +// TryEat 右键食物:饥饿未满则开始 1.6s 食用(音频.md §6.1)。 +func (s *Session) TryEat() bool { + if s.Dead || s.eatLeft > 0 || s.Hunger >= 20 { + return false + } + sel := s.Inv.SelectedStack() + d, ok := s.Items.Get(sel.Name) + if !ok || d.Food <= 0 { + return false + } + s.eatLeft = 1.6 + s.eatName = sel.Name + s.eatAcc = 0 + return true +} + +func (s *Session) tickEat(dt float64) { + if s.eatLeft <= 0 { + return + } + s.eatLeft -= dt + s.eatAcc += dt + if s.eatAcc >= 0.2 { + s.eatAcc = 0 + if s.Sound != nil { + s.Sound("entity.generic.eat", s.Player.Pos[0], s.Player.Pos[1]+1.5, s.Player.Pos[2]) + } + } + if s.eatLeft > 0 { + return + } + d, ok := s.Items.Get(s.eatName) + if ok { + s.Hunger += d.Food + if s.Hunger > 20 { + s.Hunger = 20 + } + s.Saturation += d.Saturation + if s.Saturation > float64(s.Hunger) { + s.Saturation = float64(s.Hunger) + } + s.Inv.Remove(s.eatName, 1) + } + s.eatName = "" + if s.Sound != nil { + s.Sound("entity.player.burp", s.Player.Pos[0], s.Player.Pos[1]+1.5, s.Player.Pos[2]) + } } diff --git a/internal/game/game_test.go b/internal/game/game_test.go index 070020a6..c3c858f9 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -281,7 +281,7 @@ func TestAttackAnimal(t *testing.T) { s.Cam.Pitch = -0.2 // 平视前方(测试会话默认向下看,攻击需改平视) // 玩家正前方 3 格生成动物(与相机射线同向) e := s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]-3) - e.Data = &entity.Animal{} + e.Data = &entity.Animal{Species: 0} if !s.Attack() { t.Fatal("正前方动物应可攻击") } @@ -299,7 +299,7 @@ func TestAttackAnimal(t *testing.T) { if s.Entities.Get(e.ID) != nil { t.Fatal("死亡后实体应被移除") } - // 掉落物:肉类(按实体 ID 轮换 beef/porkchop/mutton/chicken) + // 掉落物:肉类(按 Animal.Species) found := false for _, d := range s.Entities.List() { if d.Kind == entity.KindItemDrop { @@ -346,3 +346,572 @@ func TestBlockDropVanilla(t *testing.T) { t.Fatalf("草方块破坏应掉落泥土(原版行为),破坏前 %d 后 %d", before, after) } } + +func countItem(s *Session, name string) int { + n := 0 + for _, st := range s.Inv.Slots { + if st.Name == name { + n += int(st.Count) + } + } + return n +} + +func TestStoneDropRequiresPickaxe(t *testing.T) { + s := newTestSession(t) + s.BeginBreak() + h := s.BreakTarget() + s.EndBreak() + sid, ok := s.Reg.ID("stone") + if !ok { + t.Fatal("stone 未注册") + } + s.World.SetBlock(h.X, h.Y, h.Z, block.NewState(sid, 0)) + s.Inv.Slots[0] = item.Stack{} + s.BeginBreak() + for s.breaking { + s.Tick(1.0 / 20) + } + time.Sleep(1100 * time.Millisecond) + s.Tick(1.0 / 20) + if n := countItem(s, "cobblestone"); n != 0 { + t.Fatalf("空手挖石头不应掉圆石,实际 %d", n) + } + s.World.SetBlock(h.X, h.Y, h.Z, block.NewState(sid, 0)) + s.Inv.Slots[0] = item.Stack{Name: "diamond_pickaxe", Count: 1} + s.BeginBreak() + for s.breaking { + s.Tick(1.0 / 20) + } + time.Sleep(1100 * time.Millisecond) + s.Tick(1.0 / 20) + if n := countItem(s, "cobblestone"); n < 1 { + t.Fatal("镐挖石头应掉圆石") + } +} + +func TestEatFood(t *testing.T) { + s := newTestSession(t) + s.Hunger = 10 + s.Inv.Slots[0] = item.Stack{Name: "beef", Count: 2} + if !s.TryEat() { + t.Fatal("饥饿未满应能吃牛肉") + } + for i := 0; i < 40; i++ { + s.Tick(1.0 / 20) + } + if s.Hunger <= 10 { + t.Fatalf("食用后饥饿应增加,实际 %d", s.Hunger) + } + if s.Inv.Slots[0].Count != 1 { + t.Fatalf("应消耗 1 个牛肉,剩余 %d", s.Inv.Slots[0].Count) + } +} + +// TestHoldBreakContinues 按住续挖:同目标不重置;挖完再 BeginBreak 能锁定下一块。 +func TestHoldBreakContinues(t *testing.T) { + s := newTestSession(t) + s.BeginBreak() + if !s.Breaking() { + t.Fatal("应锁定破坏目标") + } + tx, ty, tz := s.BreakTarget().X, s.BreakTarget().Y, s.BreakTarget().Z + s.Tick(1.0 / 20) + prog := s.BreakProgress() + s.ContinueBreak() + if s.BreakTarget().X != tx || s.BreakTarget().Y != ty || s.BreakTarget().Z != tz { + t.Fatal("同准星不应换目标") + } + if s.BreakProgress() < prog-1e-9 { + t.Fatalf("同目标不应重置进度: %v → %v", prog, s.BreakProgress()) + } + for s.Breaking() { + s.Tick(1.0 / 20) + } + if s.World.Block(tx, ty, tz) != block.Air { + t.Fatal("第一块应已破坏") + } + s.BeginBreak() + if !s.Breaking() { + t.Fatal("挖完后应能立刻开始下一块") + } +} + +func TestAttackSoundsNoGeneric(t *testing.T) { + s := newTestSession(t) + s.Cam.Pitch = -0.2 + e := s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]-3) + e.Data = &entity.Animal{Species: 0} + var keys []string + s.Sound = func(k string, _, _, _ float64) { keys = append(keys, k) } + if !s.Attack() { + t.Fatal("应命中") + } + if len(keys) != 2 || keys[0] != "entity.player.attack.strong" || keys[1] != hurtSound(e) { + t.Fatalf("命中应先 strong 再物种 hurt,实际 %v", keys) + } + for _, k := range keys { + if k == "entity.player.attack.nodamage" { + t.Fatal("命中不应播 nodamage") + } + } + for i := 0; i < 10; i++ { + cur := s.Entities.Get(e.ID) + if cur == nil || cur.Dead { + break + } + s.Attack() + } + if len(keys) < 3 || keys[len(keys)-1] != deathSound(e) { + t.Fatalf("致死应播 death,实际 %v", keys) + } + for _, k := range keys { + if k == "entity.generic.hurt" { + t.Fatal("不应叠播 generic.hurt") + } + } +} + +func TestSwingMissNoBlock(t *testing.T) { + s := newTestSession(t) + s.Cam.Pitch = 1.2 // 抬头:无实体无方块 + var keys []string + s.Sound = func(k string, _, _, _ float64) { keys = append(keys, k) } + if s.Attack() { + t.Fatal("抬头不应命中实体") + } + if _, ok := s.Cast(5); ok { + t.Fatal("抬头不应命中方块") + } + s.SwingMiss() + if len(keys) != 1 || keys[0] != "entity.player.attack.nodamage" { + t.Fatalf("挥空应只播 nodamage,实际 %v", keys) + } +} + +func TestFallSounds(t *testing.T) { + s := newTestSession(t) + s.Player.Flying = false + s.Player.InWater = false + var keys []string + s.Sound = func(k string, _, _, _ float64) { keys = append(keys, k) } + + s.Player.OnGround = false + s.tickFallDamage(1, -3.5) // 累计 fallDist=3.5 + s.Player.OnGround = true + s.tickFallDamage(0.05, -4) // 无伤害 → small_fall + if len(keys) != 1 || keys[0] != "entity.player.small_fall" { + t.Fatalf("无伤害落地应 small_fall,实际 %v", keys) + } + + keys = nil + s.Player.OnGround = false + s.tickFallDamage(1, -5) + s.Player.OnGround = true + s.tickFallDamage(0.05, -20) // 有伤害 → big_fall + hurt + if len(keys) < 2 || keys[0] != "entity.player.big_fall" || keys[1] != "entity.player.hurt" { + t.Fatalf("有伤害落地应 big_fall+hurt,实际 %v", keys) + } + + keys = nil + s.Player.Flying = true + s.fallDist = 8 + s.Player.OnGround = true + s.tickFallDamage(0.05, -20) + if len(keys) != 0 { + t.Fatalf("飞行不应播坠落音,实际 %v", keys) + } +} + +func TestSpawnAnimalSpecies(t *testing.T) { + s := newTestSession(t) + for i := 0; i < 16; i++ { + s.animalTimer = 0 + s.spawnAnimals(0) + } + var e *entity.Entity + for _, it := range s.Entities.List() { + if it.Kind == entity.KindAnimal { + e = it + break + } + } + if e == nil { + e = s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]-4) + e.Data = &entity.Animal{Species: s.rng.Intn(4)} + } + d, ok := e.Data.(*entity.Animal) + if !ok { + t.Fatal("刷怪应写入 Animal.Data") + } + if d.Species < 0 || d.Species > 3 { + t.Fatalf("Species 越界: %d", d.Species) + } + if entity.AnimalSpecies(e) != d.Species { + t.Fatalf("AnimalSpecies=%d Species=%d", entity.AnimalSpecies(e), d.Species) + } +} + +func TestMineHitEvery4Ticks(t *testing.T) { + s := newTestSession(t) + obs, ok := s.Reg.ID("obsidian") + if !ok { + t.Fatal("无黑曜石") + } + h, ok := s.Cast(5) + if !ok { + t.Fatal("应对准方块") + } + s.World.SetBlock(h.X, h.Y, h.Z, block.NewState(obs, 0)) + var hits int + s.Sound = func(k string, _, _, _ float64) { + if len(k) >= 4 && k[len(k)-4:] == ".hit" { + hits++ + } + } + s.BeginBreak() + if hits != 0 { + t.Fatal("BeginBreak 不应单独播 hit") + } + for i := 0; i < 8; i++ { + s.Tick(1.0 / 20) + } + if hits < 2 { + t.Fatalf("8 tick 应至少 2 次 hit(0 与 4),实际 %d", hits) + } +} + +func TestFootstepWalkDist(t *testing.T) { + s := newTestSession(t) + s.Player.OnGround = true + s.Player.Vel = [3]float64{10, 0, 0} + s.walkDist = 0.99 + s.nextStep = 1 + var steps int + s.Sound = func(k string, _, _, _ float64) { + if len(k) >= 5 && k[len(k)-5:] == ".step" { + steps++ + } + } + s.tickFootsteps(0.05) + if steps != 1 { + t.Fatalf("跨越整数应播 1 次脚步,实际 %d", steps) + } +} + +func TestAnimalAmbientSound(t *testing.T) { + s := newTestSession(t) + e := s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]-2) + e.AmbientT = 1000 + var got []string + s.Sound = func(k string, _, _, _ float64) { got = append(got, k) } + s.tickEntitySounds(0.05) + want := ambientSound(e) + found := false + for _, k := range got { + if k == want { + found = true + } + } + if !found { + t.Fatalf("应播 %s,实际 %v", want, got) + } + if e.AmbientT != -80 { + t.Fatalf("ambient 后应重置为 -80,实际 %d", e.AmbientT) + } +} + +// TestInvCraft2x2 物品栏 2×2:木板 → 工作台;关闭余料退回。 +func TestInvCraft2x2(t *testing.T) { + s := newTestSession(t) + for i := 0; i < 4; i++ { + s.InvCraftClick(i, item.Stack{Name: "oak_planks", Count: 1}, false) + } + res, ok := s.InvCraftResult() + if !ok || res.Name != "crafting_table" { + t.Fatalf("2×2 木板应产出工作台,实际 %+v ok=%v", res, ok) + } + taken, ok := s.InvCraftTakeResult() + if !ok || taken.Name != "crafting_table" { + t.Fatalf("TakeResult 应返回工作台,实际 %+v", taken) + } + // 3×3 工具配方不应在 2×2 匹配 + s.InvCraftClick(0, item.Stack{Name: "oak_planks", Count: 1}, false) + s.InvCraftClick(1, item.Stack{Name: "oak_planks", Count: 1}, false) + s.InvCraftClick(2, item.Stack{Name: "oak_planks", Count: 1}, false) + if _, ok := s.InvCraftResult(); ok { + t.Fatal("2×2 不应匹配 3 宽镐配方") + } + s.CloseInvCraft() + have := 0 + for _, st := range s.Inv.Slots { + if st.Name == "oak_planks" { + have += int(st.Count) + } + } + if have < 3 { + t.Fatalf("关闭后木板应退回,实际 %d", have) + } +} + +// TestCraftingTakeResultAll Shift 批量:8 原木无序合成 32 木板进背包。 +func TestCraftingTakeResultAll(t *testing.T) { + s := newTestSession(t) + s.Inv.Slots[1] = item.Stack{} // 去掉默认 10 木板,便于计数 + s.OpenCrafting() + s.CraftingSwap(0, item.Stack{Name: "oak_log", Count: 8}) + n := s.CraftingTakeResultAll() + if n != 8 { + t.Fatalf("应批量 8 次,实际 %d", n) + } + have := 0 + for _, st := range s.Inv.Slots { + if st.Name == "oak_planks" { + have += int(st.Count) + } + } + if have != 32 { + t.Fatalf("应产出 32 木板,实际 %d", have) + } +} + +// TestCraftingFillRecipe 配方书填格:背包有材料才填。 +func TestCraftingFillRecipe(t *testing.T) { + s := newTestSession(t) + s.Inv.Slots[1] = item.Stack{} + s.OpenCrafting() + var table *recipe.Recipe + for _, rc := range s.Recipes.CraftingRecipes() { + if rc.Result.Item == "crafting_table" { + table = rc + break + } + } + if table == nil { + t.Fatal("缺少工作台配方") + } + if s.CraftingFillRecipe(table) { + t.Fatal("材料不足不应填格") + } + s.Inv.Add(item.Stack{Name: "oak_planks", Count: 4}) + if !s.CraftingFillRecipe(table) { + t.Fatal("有 4 木板应能填工作台配方") + } + res, ok := s.CraftingResult() + if !ok || res.Name != "crafting_table" { + t.Fatalf("填格后应可合成工作台,实际 %+v", res) + } +} + +func TestOpenFurnaceAndSmelt(t *testing.T) { + s := newTestSession(t) + s.Inv.Slots[1] = item.Stack{} + fid, ok := s.Reg.ID("furnace") + if !ok { + t.Fatal("缺少熔炉方块") + } + ey := int32(s.Player.Eye()[1]) + s.World.SetBlock(8, ey, 7, block.NewState(fid, 0)) + s.BlockEntities[[3]int32{8, ey, 7}] = blockentity.NewFurnace(8, ey, 7) + s.Player.Pos = [3]float64{8.5, s.Player.Pos[1], 8.5} + s.Cam.Pos = s.Player.Eye() + s.Cam.Yaw, s.Cam.Pitch = 0, -0.15 + if !s.TryOpenFurnace() { + t.Fatal("应打开熔炉") + } + be := s.ContainerEntity() + be.Slots[blockentity.SlotInput] = item.Stack{Name: "iron_ore", Count: 1} + be.Slots[blockentity.SlotFuel] = item.Stack{Name: "coal", Count: 1} + for i := 0; i < 220; i++ { + s.Tick(0.05) + } + if be.Slots[blockentity.SlotOutput].Name != "iron_ingot" { + t.Fatalf("烧完应产出铁锭,实际 %+v", be.Slots[blockentity.SlotOutput]) + } +} + +func TestChestPutTakeClose(t *testing.T) { + s := newTestSession(t) + s.Inv.Slots[1] = item.Stack{} + cid, ok := s.Reg.ID("chest") + if !ok { + t.Fatal("缺少箱子方块") + } + ey := int32(s.Player.Eye()[1]) + s.World.SetBlock(8, ey, 7, block.NewState(cid, 0)) + s.BlockEntities[[3]int32{8, ey, 7}] = blockentity.NewChest(8, ey, 7) + s.Player.Pos = [3]float64{8.5, s.Player.Pos[1], 8.5} + s.Cam.Pos = s.Player.Eye() + s.Cam.Yaw, s.Cam.Pitch = 0, -0.15 + if !s.TryOpenChest() { + t.Fatal("应打开箱子") + } + be := s.ContainerEntity() + be.Slots[0] = item.Stack{Name: "oak_planks", Count: 5} + s.CloseContainer() + if s.ContainerOpen() != 0 { + t.Fatal("关闭后不应仍打开") + } + if !s.TryOpenChest() { + t.Fatal("重开箱子") + } + be = s.ContainerEntity() + if be.Slots[0].Name != "oak_planks" || be.Slots[0].Count != 5 { + t.Fatalf("关箱不应丢物品: %+v", be.Slots[0]) + } +} + +func TestBlockEntityPersist(t *testing.T) { + s := newTestSession(t) + f := blockentity.NewFurnace(3, 64, 4) + f.Slots[blockentity.SlotInput] = item.Stack{Name: "iron_ore", Count: 2} + f.Slots[blockentity.SlotFuel] = item.Stack{Name: "oak_planks", Count: 3} + f.BurnTicks, f.BurnMaxTicks, f.CookTicks = 80, 300, 40 + s.BlockEntities[[3]int32{3, 64, 4}] = f + snaps := s.ExportBlockEntities() + s.ImportBlockEntities(snaps) + got := s.BlockEntities[[3]int32{3, 64, 4}] + if got == nil || got.CookTicks != 40 || got.Slots[blockentity.SlotFuel].Count != 3 { + t.Fatalf("方块实体读回应保留进度: %+v", got) + } +} + +func TestUnlockAndDiscover(t *testing.T) { + s := newTestSession(t) + s.Inv.Slots[1] = item.Stack{Name: "oak_planks", Count: 64} + s.DiscoverCraftable() + if !s.RecipeUnlocked("crafting_table") && !s.RecipeUnlocked("stick") { + t.Fatal("背包有大量木板应解锁工作台或木棍") + } + s.UnlockRecipe("chest") + if !s.RecipeUnlocked("chest") { + t.Fatal("UnlockRecipe 应写入") + } + list := s.ExportUnlocked() + s.ImportUnlocked(list) + if !s.RecipeUnlocked("chest") { + t.Fatal("解锁列表读回应保留") + } +} + +func TestCloseInvCraftNoLoss(t *testing.T) { + s := newTestSession(t) + s.InvCraftClick(0, item.Stack{Name: "oak_log", Count: 3}, false) + s.CloseInvCraft() + have := 0 + for _, st := range s.Inv.Slots { + if st.Name == "oak_log" { + have += int(st.Count) + } + } + if have != 3 { + t.Fatalf("关闭物品栏不应丢原木,实际 %d", have) + } +} + +func TestToolDurabilityBreaksAtZero(t *testing.T) { + s := newTestSession(t) + s.Inv.Slots[0] = item.Stack{Name: "diamond_pickaxe", Count: 1, Durability: 1} + s.Inv.Slots[1] = item.Stack{} + s.BeginBreak() + if !s.breaking { + t.Fatal("应锁定破坏目标") + } + for s.breaking { + s.Tick(1.0 / 20) + } + if !s.Inv.Slots[0].Empty() { + t.Fatalf("耐久归零应销毁工具,实际 %+v", s.Inv.Slots[0]) + } +} + +func TestHungerDrainAndStarve(t *testing.T) { + s := newTestSession(t) + s.Hunger = 20 + s.Saturation = 0 + s.AddExhaustion(4) + if s.Hunger != 19 { + t.Fatalf("4 点消耗应扣 1 饥饿,实际 %d", s.Hunger) + } + s.Hunger = 0 + s.Health = 5 + s.starveAcc = 0 + for i := 0; i < 85; i++ { // ≥4 秒 + s.Tick(1.0 / 20) + } + if s.Health >= 5 { + t.Fatalf("饥饿 0 应掉血,实际 %d", s.Health) + } +} + +func TestDeathDoesNotInstantRespawn(t *testing.T) { + s := newTestSession(t) + s.Spawn = s.Player.Pos + s.Health = 1 + s.kill("测试") + if !s.IsDead() || s.Health != 0 { + t.Fatal("致死应进入死亡状态且生命为 0") + } + pos := s.Player.Pos + s.Tick(1.0 / 20) + if s.Player.Pos != pos { + t.Fatal("死亡后不应瞬移") + } + if s.Health != 0 { + t.Fatalf("未点重生前生命应为 0,实际 %d", s.Health) + } + s.Respawn() + if s.IsDead() || s.Health != 20 { + t.Fatal("重生应满血并清除死亡") + } +} + +func TestTickAnimalWired(t *testing.T) { + s := newTestSession(t) + e := s.Entities.Spawn(entity.KindAnimal, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]) + e.Data = &entity.Animal{Species: 0, BreedCooldown: 3} + s.Entities.Tick(1.0/20, s.World, s.Player.Pos) + if e.Data.(*entity.Animal).BreedCooldown != 2 { + t.Fatalf("TickAnimal 应扣冷却,实际 %d", e.Data.(*entity.Animal).BreedCooldown) + } +} + +func TestExecuteTrade(t *testing.T) { + s := newTestSession(t) + tbl, err := entity.LoadTrades(filepath.Join("..", "..", "assets", "config", "villager_trades.json")) + if err != nil { + t.Fatal(err) + } + s.Trades = tbl + v := s.Entities.Spawn(entity.KindVillager, s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]) + v.Data = &entity.Villager{Profession: 0} + s.tradeOpen = true + s.tradeID = v.ID + s.Inv.Slots[2] = item.Stack{Name: "wheat", Count: 20} + if !s.ExecuteTrade(0) { + t.Fatal("20 小麦应能换腐肉") + } + if s.Inv.CountItem("rotten_flesh") < 1 { + t.Fatal("应得到腐肉") + } + if s.Inv.CountItem("wheat") != 0 { + t.Fatalf("小麦应耗尽,剩余 %d", s.Inv.CountItem("wheat")) + } +} + +func TestLeverPowersDust(t *testing.T) { + s := newTestSession(t) + dust, ok1 := s.Reg.ID("redstone_dust") + lever, ok2 := s.Reg.ID("lever") + if !ok1 || !ok2 { + t.Fatal("缺少红石尘/拉杆方块") + } + y := int32(s.Player.Pos[1]) + s.World.SetBlock(8, y, 8, block.NewState(lever, 1)) + s.World.SetBlock(9, y, 8, block.NewState(dust, 0)) + s.rsDirty = true + s.refreshWires() + if s.World.Block(9, y, 8).Meta() == 0 { + t.Fatal("开启的拉杆应给相邻红石尘充能") + } +} diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go index eb69575c..f8d12328 100644 --- a/internal/inventory/inventory.go +++ b/internal/inventory/inventory.go @@ -1,23 +1,32 @@ // Package inventory 背包模型与规则表(场景/物品与背包.md §3、UI与输入.md §4)。 // -// 规则表(CanPlace / CanMerge / Merge)为**单一来源**:客户端与服务器共用同一实现, -// 防双端不一致(多人同步.md §5)。 +// 规则表(CanPlace / ClickLeft / ClickRight / ShiftMove / Merge)为**单一来源**: +// 客户端与服务器共用同一实现,防双端不一致(多人同步.md §5)。 package inventory import ( "mc/internal/item" ) -// 背包槽位常量(UI还原.md §3.3:27 主区 + 9 热键栏)。 +// 背包槽位常量(UI还原.md §3.3、物品与背包.md §3)。 const ( - HotbarSize = 9 // 热键栏槽数 - MainSize = 27 // 主物品区槽数 - TotalSize = HotbarSize + MainSize + HotbarSize = 9 + MainSize = 27 + StorageSize = HotbarSize + MainSize // 0–35,Add/Remove 只写这里 + ArmorSize = 4 + OffhandSize = 1 + TotalSize = StorageSize + ArmorSize + OffhandSize // 41 + SlotHelmet = 36 + SlotChest = 37 + SlotLegs = 38 + SlotBoots = 39 + SlotOffhand = 40 + MaxCraftBulk = 64 // Shift 批量合成单次上限(工作台与合成表.md §5) ) // Inventory 背包(主线程所有权,无需锁——架构.md §4)。 type Inventory struct { - Slots []item.Stack // 36 槽 + Slots []item.Stack // 41 槽:0–8 热键栏、9–35 主区、36–39 盔甲、40 副手 Selected int // 热键栏选中索引 0–8 reg *item.Registry } @@ -50,14 +59,97 @@ func Merge(limit uint8, a, b item.Stack) (item.Stack, item.Stack) { return a, b } -// Add 把物品栈放入背包(先热键栏选中槽,再顺序找空/可合并槽),返回剩余。 +// CanPlace 槽位是否允许放入该栈(盔甲/副手按 Equip 限制,其余存储槽均可)。 +func CanPlace(reg *item.Registry, slot int, s item.Stack) bool { + if s.Empty() { + return true + } + want := "" + switch slot { + case SlotHelmet: + want = "helmet" + case SlotChest: + want = "chestplate" + case SlotLegs: + want = "leggings" + case SlotBoots: + want = "boots" + case SlotOffhand: + want = "offhand" + default: + return true + } + return item.EquipOf(s.Name, reg) == want +} + +// ClickLeft 左键:拿起整栈 / 放下 / 同物品合并 / 交换。canPlace 为 false 时不改槽。 +func ClickLeft(slot, held item.Stack, limit uint8, canPlace bool) (item.Stack, item.Stack) { + switch { + case held.Empty(): + return item.Stack{}, slot + case !canPlace: + return slot, held + case slot.Empty(): + return held, item.Stack{} + case slot.Name == held.Name: + merged, remain := Merge(limit, slot, held) + return merged, remain + default: + return held, slot + } +} + +// ClickRight 右键:空手拿一半(向上取整);手持时向空槽或同物品放 1 个。 +func ClickRight(slot, held item.Stack, limit uint8, canPlace bool) (item.Stack, item.Stack) { + if held.Empty() { + if slot.Empty() { + return slot, held + } + take := (int(slot.Count) + 1) / 2 + held = slot + held.Count = uint8(take) + slot.Count -= uint8(take) + if slot.Count == 0 { + slot = item.Stack{} + } + return slot, held + } + if !canPlace { + return slot, held + } + one := held + one.Count = 1 + switch { + case slot.Empty(): + slot = one + held.Count-- + case slot.Name == held.Name && slot.Count < limit: + slot.Count++ + held.Count-- + default: + return slot, held + } + if held.Count == 0 { + held = item.Stack{} + } + return slot, held +} + +func (inv *Inventory) storageEnd() int { + if len(inv.Slots) < StorageSize { + return len(inv.Slots) + } + return StorageSize +} + +// Add 把物品栈放入存储区(0–35;先合并再空槽),返回剩余。 func (inv *Inventory) Add(s item.Stack) item.Stack { if s.Empty() { return s } limit := s.Limit(inv.reg) - // 第一遍:合并到已有同类 - for i := range inv.Slots { + end := inv.storageEnd() + for i := 0; i < end; i++ { if inv.Slots[i].Empty() { continue } @@ -70,23 +162,47 @@ func (inv *Inventory) Add(s item.Stack) item.Stack { } } } - // 第二遍:放入空槽 - for i := range inv.Slots { + for i := 0; i < end; i++ { if inv.Slots[i].Empty() { inv.Slots[i] = s return item.Stack{} } } - return s // 放不下:返回剩余 + return s } -// Remove 从背包移除指定物品 count 个;不足返回 false。 +// Fits 存储区能否完全放下该栈(不修改)。 +func (inv *Inventory) Fits(s item.Stack) bool { + if s.Empty() { + return true + } + limit := s.Limit(inv.reg) + left := int(s.Count) + end := inv.storageEnd() + for i := 0; i < end; i++ { + st := inv.Slots[i] + if st.Empty() { + return true + } + if st.Name == s.Name && st.Durability == s.Durability && st.Count < limit { + room := int(limit) - int(st.Count) + if room >= left { + return true + } + left -= room + } + } + return false +} + +// Remove 从存储区移除指定物品 count 个;不足返回 false。 func (inv *Inventory) Remove(name string, count int) bool { if count <= 0 { return false } have := 0 - for i := range inv.Slots { + end := inv.storageEnd() + for i := 0; i < end; i++ { if inv.Slots[i].Name == name { have += int(inv.Slots[i].Count) } @@ -95,7 +211,7 @@ func (inv *Inventory) Remove(name string, count int) bool { return false } left := count - for i := range inv.Slots { + for i := 0; i < end; i++ { if inv.Slots[i].Name != name { continue } @@ -113,6 +229,240 @@ func (inv *Inventory) Remove(name string, count int) bool { return true } +// CountItem 存储区中该物品数量(不含盔甲/副手)。 +func (inv *Inventory) CountItem(name string) int { + n := 0 + end := inv.storageEnd() + for i := 0; i < end; i++ { + if inv.Slots[i].Name == name { + n += int(inv.Slots[i].Count) + } + } + return n +} + +// TakeOne 从存储区取出 1 个指定物品;不足返回 false。 +func (inv *Inventory) TakeOne(name string) bool { + return inv.Remove(name, 1) +} + +// ClickSlot 对背包槽执行左/右键(含 CanPlace)。 +func (inv *Inventory) ClickSlot(idx int, held item.Stack, right bool) item.Stack { + if idx < 0 || idx >= len(inv.Slots) { + return held + } + cur := inv.Slots[idx] + limit := held.Limit(inv.reg) + if held.Empty() { + limit = cur.Limit(inv.reg) + } + ok := CanPlace(inv.reg, idx, held) + var ns, nh item.Stack + if right { + ns, nh = ClickRight(cur, held, limit, ok) + } else { + ns, nh = ClickLeft(cur, held, limit, ok) + } + inv.Slots[idx] = ns + return nh +} + +// ShiftFromInv 物品栏内 Shift:主区 ↔ 热键栏;盔甲/副手 → 存储区。 +func (inv *Inventory) ShiftFromInv(idx int) bool { + if idx < 0 || idx >= len(inv.Slots) || inv.Slots[idx].Empty() { + return false + } + src := inv.Slots[idx] + inv.Slots[idx] = item.Stack{} + var rem item.Stack + switch { + case idx < HotbarSize: + rem = addRange(inv, src, HotbarSize, StorageSize) + case idx < StorageSize: + rem = addRange(inv, src, 0, HotbarSize) + default: + rem = inv.Add(src) + } + if !rem.Empty() && CanPlace(inv.reg, idx, rem) { + inv.Slots[idx] = rem + } + return rem.Empty() || rem.Count != src.Count +} + +// ShiftIntoStorage 把一栈移入存储区(工作台网格 → 背包)。 +func (inv *Inventory) ShiftIntoStorage(s item.Stack) item.Stack { + return inv.Add(s) +} + +// ShiftIntoSlots 把一栈尽量放入 dest 切片的空/可合并槽(背包 → 合成网格)。 +func ShiftIntoSlots(reg *item.Registry, dest []item.Stack, s item.Stack) item.Stack { + if s.Empty() { + return s + } + limit := s.Limit(reg) + for i := range dest { + if dest[i].Empty() || dest[i].Name != s.Name || dest[i].Durability != s.Durability { + continue + } + merged, remain := Merge(limit, dest[i], s) + dest[i] = merged + s = remain + if s.Empty() { + return s + } + } + for i := range dest { + if dest[i].Empty() { + dest[i] = s + return item.Stack{} + } + } + return s +} + +func addRange(inv *Inventory, s item.Stack, from, to int) item.Stack { + if s.Empty() || from < 0 || to > len(inv.Slots) || from >= to { + return s + } + limit := s.Limit(inv.reg) + for i := from; i < to; i++ { + if inv.Slots[i].Empty() || inv.Slots[i].Name != s.Name || inv.Slots[i].Durability != s.Durability { + continue + } + if inv.Slots[i].Count >= limit { + continue + } + merged, remain := Merge(limit, inv.Slots[i], s) + inv.Slots[i] = merged + s = remain + if s.Empty() { + return s + } + } + for i := from; i < to; i++ { + if inv.Slots[i].Empty() { + inv.Slots[i] = s + return item.Stack{} + } + } + return s +} + +// SlotPtr 槽位指针(拖拽均分用);越界返回 nil。 +func SlotPtr(slots []item.Stack, i int) *item.Stack { + if i < 0 || i >= len(slots) { + return nil + } + return &slots[i] +} + +// CanAcceptDrag 拖拽目标:空槽或同物品未满。 +func CanAcceptDrag(reg *item.Registry, slot, held item.Stack, canPlace bool) bool { + if held.Empty() || !canPlace { + return false + } + if slot.Empty() { + return true + } + return slot.Name == held.Name && slot.Durability == held.Durability && slot.Count < held.Limit(reg) +} + +// SpreadEven 把 held 等分到 dest(已过滤可放入的槽)。返回剩余手持。 +// dest 为槽指针列表;n=0 不改。 +func SpreadEven(reg *item.Registry, held item.Stack, dest []*item.Stack) item.Stack { + if held.Empty() || len(dest) == 0 { + return held + } + limit := held.Limit(reg) + n := len(dest) + each := int(held.Count) / n + if each < 1 { + return held + } + if each > int(limit) { + each = int(limit) + } + left := int(held.Count) + for _, p := range dest { + if p == nil || left <= 0 { + continue + } + put := each + if !p.Empty() { + room := int(limit) - int(p.Count) + if put > room { + put = room + } + } + if put <= 0 { + continue + } + if p.Empty() { + *p = held + p.Count = uint8(put) + } else { + p.Count += uint8(put) + } + left -= put + } + if left <= 0 { + return item.Stack{} + } + held.Count = uint8(left) + return held +} + +// SpreadOne 向单槽放 1 个(右键拖)。 +func SpreadOne(reg *item.Registry, slot, held item.Stack, canPlace bool) (item.Stack, item.Stack) { + return ClickRight(slot, held, held.Limit(reg), canPlace) +} + +// Gather 从 sources 把与 name 同类的物品聚到 held,直到堆叠上限。 +func Gather(reg *item.Registry, held item.Stack, name string, sources []item.Stack) (item.Stack, []item.Stack) { + if name == "" { + return held, sources + } + if held.Empty() { + held = item.Stack{Name: name, Count: 0} + } + if held.Name != name { + return held, sources + } + limit := held.Limit(reg) + for i := range sources { + if sources[i].Empty() || sources[i].Name != name || sources[i].Durability != held.Durability { + continue + } + need := int(limit) - int(held.Count) + if need <= 0 { + break + } + take := int(sources[i].Count) + if take > need { + take = need + } + held.Count += uint8(take) + if take >= int(sources[i].Count) { + sources[i] = item.Stack{} + } else { + sources[i].Count -= uint8(take) + } + } + if held.Count == 0 { + held = item.Stack{} + } + return held, sources +} + +// GatherStorage 从存储区 0–35 聚堆到 held。 +func (inv *Inventory) GatherStorage(held item.Stack, name string) item.Stack { + end := inv.storageEnd() + src := inv.Slots[:end] + held, src = Gather(inv.reg, held, name, src) + copy(inv.Slots[:end], src) + return held +} + // SelectedStack 返回选中槽(只读)。 func (inv *Inventory) SelectedStack() item.Stack { if inv.Selected < 0 || inv.Selected >= HotbarSize { diff --git a/internal/inventory/inventory_test.go b/internal/inventory/inventory_test.go index 7ddcea57..52e81da0 100644 --- a/internal/inventory/inventory_test.go +++ b/internal/inventory/inventory_test.go @@ -67,3 +67,89 @@ func TestSelected(t *testing.T) { t.Fatalf("选中槽异常: %+v", s) } } + +func TestCanPlaceArmor(t *testing.T) { + reg := loadItemReg(t) + planks := item.Stack{Name: "oak_planks", Count: 1} + helm := item.Stack{Name: "iron_helmet", Count: 1} + if CanPlace(reg, SlotHelmet, planks) { + t.Fatal("盔甲槽应拒收木板") + } + if !CanPlace(reg, SlotHelmet, helm) { + t.Fatal("头盔槽应接收铁头盔") + } + if CanPlace(reg, SlotChest, helm) { + t.Fatal("胸甲槽应拒收头盔") + } + if !CanPlace(reg, SlotOffhand, item.Stack{Name: "shield", Count: 1}) { + t.Fatal("副手应接收盾") + } + if CanPlace(reg, SlotOffhand, planks) { + t.Fatal("副手应拒收木板") + } +} + +func TestClickRightHalf(t *testing.T) { + slot := item.Stack{Name: "oak_planks", Count: 5} + ns, nh := ClickRight(slot, item.Stack{}, 64, true) + if nh.Count != 3 || ns.Count != 2 { + t.Fatalf("空手右键应拿一半向上取整:槽=%d 手=%d", ns.Count, nh.Count) + } + ns, nh = ClickRight(item.Stack{}, nh, 64, true) + if ns.Count != 1 || nh.Count != 2 { + t.Fatalf("手持右键应放 1 个:槽=%d 手=%d", ns.Count, nh.Count) + } +} + +func TestShiftHotbarMain(t *testing.T) { + reg := loadItemReg(t) + inv := New(reg) + inv.Slots[0] = item.Stack{Name: "stick", Count: 8} + if !inv.ShiftFromInv(0) { + t.Fatal("热键栏 Shift 应移到主区") + } + if !inv.Slots[0].Empty() || inv.Slots[9].Name != "stick" || inv.Slots[9].Count != 8 { + t.Fatalf("应进入主区槽 9,实际 hot=%+v main=%+v", inv.Slots[0], inv.Slots[9]) + } +} + +func TestAddSkipsArmor(t *testing.T) { + reg := loadItemReg(t) + inv := New(reg) + for i := 0; i < StorageSize; i++ { + inv.Slots[i] = item.Stack{Name: "dirt", Count: 64} + } + rem := inv.Add(item.Stack{Name: "oak_planks", Count: 1}) + if rem.Empty() { + t.Fatal("存储满时不应写入盔甲槽") + } + if !inv.Slots[SlotHelmet].Empty() { + t.Fatal("盔甲槽应保持空") + } +} + +func TestSpreadEven(t *testing.T) { + reg := loadItemReg(t) + slots := []item.Stack{{}, {}, {}, {}, {}} + ptrs := []*item.Stack{&slots[0], &slots[1], &slots[2], &slots[3], &slots[4]} + held := SpreadEven(reg, item.Stack{Name: "oak_planks", Count: 5}, ptrs) + if !held.Empty() { + t.Fatalf("5 个应均分完,剩余 %+v", held) + } + for i, st := range slots { + if st.Count != 1 { + t.Fatalf("槽 %d 应各 1,实际 %+v", i, st) + } + } +} + +func TestGather(t *testing.T) { + reg := loadItemReg(t) + inv := New(reg) + inv.Slots[0] = item.Stack{Name: "stick", Count: 10} + inv.Slots[9] = item.Stack{Name: "stick", Count: 20} + held := inv.GatherStorage(item.Stack{}, "stick") + if held.Count != 30 { + t.Fatalf("应聚 30 棍,实际 %d", held.Count) + } +} diff --git a/internal/item/item.go b/internal/item/item.go index b0226938..52183812 100644 --- a/internal/item/item.go +++ b/internal/item/item.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "sort" + "strings" ) // Def 物品定义(items.json 条目)。 @@ -18,6 +19,9 @@ type Def struct { Tier int `json:"tier"` // 工具等级门槛(挖矿与矿物.md §3) Speed float64 `json:"speed"` // 挖掘速度倍率 Durability int `json:"durability"` + Equip string `json:"equip"` // helmet / chestplate / leggings / boots / offhand + Food int `json:"food"` // 饥饿恢复(0 不可食用) + Saturation float64 `json:"saturation"` } // Registry 物品注册表。 @@ -84,3 +88,28 @@ func (s Stack) Limit(reg *Registry) uint8 { } return 64 } + +// EquipOf 装备槽类型:helmet / chestplate / leggings / boots / offhand;非装备返回空。 +func EquipOf(name string, reg *Registry) string { + if name == "" { + return "" + } + if reg != nil { + if d, ok := reg.Get(name); ok && d.Equip != "" { + return d.Equip + } + } + switch { + case strings.HasSuffix(name, "_helmet"): + return "helmet" + case strings.HasSuffix(name, "_chestplate"): + return "chestplate" + case strings.HasSuffix(name, "_leggings"): + return "leggings" + case strings.HasSuffix(name, "_boots"): + return "boots" + case name == "shield" || strings.HasSuffix(name, "_shield"): + return "offhand" + } + return "" +} diff --git a/internal/logx/log.go b/internal/logx/log.go index 7fc3c0db..ec6aa63f 100644 --- a/internal/logx/log.go +++ b/internal/logx/log.go @@ -23,16 +23,33 @@ const ( LevelError ) +const defaultRing = 8 + // Logger 线程安全分级日志器。 type Logger struct { - mu sync.Mutex - level Level - w io.Writer + mu sync.Mutex + level Level + w io.Writer + closer io.Closer + ring []string + ringCap int } -// New 创建日志器;file 为空仅输出 stderr。 +// New 创建日志器;file 为空仅输出 stderr,否则 stderr + 文件。 func New(file string, level Level) (*Logger, error) { - w := io.Writer(os.Stderr) + return newLogger(file, level, true, defaultRing) +} + +// NewFile 仅写文件(GUI 客户端:无 CMD,构建与发布.md §1)。file 为空则丢弃。 +func NewFile(file string, level Level) (*Logger, error) { + return newLogger(file, level, false, defaultRing) +} + +func newLogger(file string, level Level, stderr bool, ringCap int) (*Logger, error) { + var writers []io.Writer + if stderr { + writers = append(writers, os.Stderr) + } if file != "" { if err := os.MkdirAll(pathDir(file), 0o755); err != nil { return nil, fmt.Errorf("logx.New mkdir: %w", err) @@ -41,9 +58,45 @@ func New(file string, level Level) (*Logger, error) { if err != nil { return nil, fmt.Errorf("logx.New %s: %w", file, err) } - w = io.MultiWriter(os.Stderr, f) + writers = append(writers, f) } - return &Logger{level: level, w: w}, nil + w := io.Writer(io.Discard) + switch len(writers) { + case 1: + w = writers[0] + case 2: + w = io.MultiWriter(writers[0], writers[1]) + } + if ringCap < 1 { + ringCap = defaultRing + } + var closer io.Closer + if file != "" { + if f, ok := writers[len(writers)-1].(*os.File); ok { + closer = f + } + } + return &Logger{level: level, w: w, closer: closer, ringCap: ringCap}, nil +} + +// Close 关闭日志文件(若有)。 +func (l *Logger) Close() error { + if l == nil || l.closer == nil { + return nil + } + return l.closer.Close() +} + +// Recent 返回最近若干行(旧→新,供 Loading 左下角)。 +func (l *Logger) Recent() []string { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + out := make([]string, len(l.ring)) + copy(out, l.ring) + return out } // pathDir 返回路径的目录部分("a.log" → ".")。 @@ -60,12 +113,27 @@ func pathDir(p string) string { } func (l *Logger) logf(lv Level, tag, format string, args ...any) { - if lv < l.level { + if l == nil || lv < l.level { return } + msg := fmt.Sprintf(format, args...) + line := fmt.Sprintf("[%s] [%s] %s", time.Now().Format("15:04:05.000"), tag, msg) l.mu.Lock() defer l.mu.Unlock() - fmt.Fprintf(l.w, "[%s] [%s] %s\n", time.Now().Format("15:04:05.000"), tag, fmt.Sprintf(format, args...)) + fmt.Fprintln(l.w, line) + l.pushRingLocked(msg) +} + +func (l *Logger) pushRingLocked(s string) { + if l.ringCap <= 0 { + return + } + if len(l.ring) < l.ringCap { + l.ring = append(l.ring, s) + return + } + copy(l.ring, l.ring[1:]) + l.ring[l.ringCap-1] = s } // Debugf 调试输出。 diff --git a/internal/physics/player.go b/internal/physics/player.go index ebeff1fd..a3ad1eef 100644 --- a/internal/physics/player.go +++ b/internal/physics/player.go @@ -8,12 +8,20 @@ import "math" // World 物理层所需的世界接口(与渲染/交互共用同一张 solid 判定表)。 type World interface { - // Solid 是否碰撞(台阶/栅栏等多 AABB 形状后置,MVP 按整方块判定)。 Solid(x, y, z int32) bool - // Fluid 是否液体(游泳/浮力判定)。 Fluid(x, y, z int32) bool } +const ( + AirMax = 15.0 // 氧气秒数(300 tick) + standHeight = 1.8 + standEye = 1.62 + sneakHeight = 1.5 + sneakEye = 1.27 + swimHeight = 0.6 + swimEye = 0.4 +) + // Player 玩家实体(设计.md §7.1:宽 0.6、高 1.8、眼高 1.62)。 type Player struct { Pos [3]float64 // 脚底坐标 @@ -23,11 +31,14 @@ type Player struct { Height float64 EyeHeight float64 - OnGround bool // 落地 - Submerged bool // 在液体中 - Flying bool // 飞行模式 + OnGround bool + InWater bool // 脚或躯干在液体 + EyeInWater bool // 眼睛在液体(氧气 / 水下雾) + Swimming bool + Flying bool + Air float64 // 剩余氧气秒数 + FallSpeed float64 // 落地前一帧垂直速度(摔落判定) - // 累积的位移余量(亚步进精度,防边界抖动) rem [3]float64 } @@ -36,8 +47,9 @@ func NewPlayer(x, y, z float64) *Player { return &Player{ Pos: [3]float64{x, y, z}, Width: 0.6, - Height: 1.8, - EyeHeight: 1.62, + Height: standHeight, + EyeHeight: standEye, + Air: AirMax, } } @@ -51,14 +63,21 @@ func (p *Player) Move(w World, dx, dy, dz float64) { p.Pos[axis] -= deltas[axis] p.Vel[axis] = 0 if axis == 1 && deltas[axis] < 0 { - p.OnGround = true // 垂直下落被阻挡 = 落地 + p.OnGround = true } } } - // 液体状态更新(头部/脚部任一在水里) - headY := p.Pos[1] + p.Height*0.6 - p.Submerged = w.Fluid(int32(math.Floor(p.Pos[0])), int32(math.Floor(p.Pos[1]+0.3)), int32(math.Floor(p.Pos[2]))) || - w.Fluid(int32(math.Floor(p.Pos[0])), int32(math.Floor(headY)), int32(math.Floor(p.Pos[2]))) + p.updateFluid(w) +} + +func (p *Player) updateFluid(w World) { + fx := int32(math.Floor(p.Pos[0])) + fz := int32(math.Floor(p.Pos[2])) + foot := w.Fluid(fx, int32(math.Floor(p.Pos[1]+0.3)), fz) + mid := w.Fluid(fx, int32(math.Floor(p.Pos[1]+p.Height*0.6)), fz) + p.InWater = foot || mid + eye := p.Eye() + p.EyeInWater = w.Fluid(int32(math.Floor(eye[0])), int32(math.Floor(eye[1])), int32(math.Floor(eye[2]))) } // Collides 当前位置 AABB 是否与固体方块重叠(含边界 epsilon 防抖动)。 @@ -87,22 +106,57 @@ func (p *Player) Eye() [3]float64 { return [3]float64{p.Pos[0], p.Pos[1] + p.EyeHeight, p.Pos[2]} } -// Tick 重力与液体阻力积分(dt 秒;调用方随后调用 Move 应用位移)。 -// 飞行/游泳模式由调用方在 Move 前覆写速度。 +// SetPose 切换潜行/游泳碰撞箱;站立会卡住则保持当前矮箱。 +func (p *Player) SetPose(w World, sneak, swim bool) { + oldH, oldE := p.Height, p.EyeHeight + switch { + case swim && p.InWater: + p.Height, p.EyeHeight, p.Swimming = swimHeight, swimEye, true + case sneak: + p.Height, p.EyeHeight, p.Swimming = sneakHeight, sneakEye, false + default: + p.Height, p.EyeHeight, p.Swimming = standHeight, standEye, false + } + if p.Collides(w) { + p.Height, p.EyeHeight = oldH, oldE + if oldH <= swimHeight+0.01 { + p.Swimming = true + } + } +} + +// TickAir 氧气:眼睛在水递减,出水回满(物理与碰撞.md §4)。 +func (p *Player) TickAir(dt float64) { + if p.EyeInWater { + p.Air -= dt + if p.Air < 0 { + p.Air = 0 + } + } else { + p.Air = AirMax + } +} + +// Tick 重力与液体阻力积分(dt 秒)。 func (p *Player) Tick(w World, dt float64) { - const gravity = 32.0 // 方块/秒² + const gravity = 32.0 + p.FallSpeed = p.Vel[1] if !p.Flying { - if p.Submerged { - p.Vel[1] -= gravity * 0.3 * dt // 水中浮力近似 - p.Vel[1] *= 1 - 2.0*dt // 阻力 + if p.InWater { + p.Vel[1] -= gravity * 0.15 * dt + p.Vel[1] *= 1 - 3.0*dt + if p.Vel[1] < -4 { + p.Vel[1] = -4 + } } else { p.Vel[1] -= gravity * dt - } - if p.Vel[1] < -64 { - p.Vel[1] = -64 // 终端速度 + if p.Vel[1] < -64 { + p.Vel[1] = -64 + } } } p.Move(w, p.Vel[0]*dt, p.Vel[1]*dt, p.Vel[2]*dt) + p.TickAir(dt) } // Jump 起跳(仅落地时生效;约 1.25 方块高度)。 @@ -112,3 +166,17 @@ func (p *Player) Jump() { p.OnGround = false } } + +// SwimUp 水中上浮(不要求落地)。 +func (p *Player) SwimUp() { + if p.InWater { + p.Vel[1] = 4.2 + } +} + +// SwimDown 水中下潜。 +func (p *Player) SwimDown() { + if p.InWater { + p.Vel[1] = -3.2 + } +} diff --git a/internal/physics/player_test.go b/internal/physics/player_test.go index f44ebda4..ba4b95ca 100644 --- a/internal/physics/player_test.go +++ b/internal/physics/player_test.go @@ -59,3 +59,47 @@ func TestJump(t *testing.T) { t.Fatalf("起跳高度过低: %v", peak) } } + +type waterWorld struct{} + +func (waterWorld) Solid(x, y, z int32) bool { return y < 50 } +func (waterWorld) Fluid(x, y, z int32) bool { return y >= 50 && y <= 56 } + +func TestOxygenDrainAndRefill(t *testing.T) { + w := waterWorld{} + p := NewPlayer(0.5, 52, 0.5) // 眼睛约 53.6,在水中 + p.updateFluid(w) + if !p.EyeInWater { + t.Fatal("眼睛应在水中") + } + p.TickAir(1) + if p.Air >= AirMax { + t.Fatalf("水下氧气应递减,实际 %v", p.Air) + } + for i := 0; i < 20; i++ { + p.TickAir(1) + } + if p.Air != 0 { + t.Fatalf("长时间潜水氧气应为 0,实际 %v", p.Air) + } + p.Pos[1] = 60 // 出水 + p.updateFluid(w) + p.TickAir(0.05) + if p.Air != AirMax { + t.Fatalf("出水应回满氧气,实际 %v", p.Air) + } +} + +func TestSwimUpWithoutGround(t *testing.T) { + w := waterWorld{} + p := NewPlayer(0.5, 53, 0.5) + p.updateFluid(w) + if !p.InWater { + t.Fatal("应在水中") + } + p.OnGround = false + p.SwimUp() + if p.Vel[1] <= 0 { + t.Fatal("水中上浮速度应 > 0") + } +} diff --git a/internal/recipe/recipe.go b/internal/recipe/recipe.go index 28c26ab9..aa7f13dd 100644 --- a/internal/recipe/recipe.go +++ b/internal/recipe/recipe.go @@ -235,3 +235,81 @@ func TrimNS(s string) string { } return s } + +// CraftingRecipes 已加载的合成配方(有序+无序,按产物名排序)。 +func (r *Registry) CraftingRecipes() []*Recipe { + if r == nil { + return nil + } + out := make([]*Recipe, 0, len(r.shaped)+len(r.shapeless)) + out = append(out, r.shaped...) + out = append(out, r.shapeless...) + sort.Slice(out, func(i, j int) bool { + if out[i].Result.Item != out[j].Result.Item { + return out[i].Result.Item < out[j].Result.Item + } + return out[i].Type < out[j].Type + }) + return out +} + +// NeedCounts 配方所需物品多重集。 +func NeedCounts(rc *Recipe) map[string]int { + need := make(map[string]int) + if rc == nil { + return need + } + switch rc.Type { + case "crafting_shapeless": + for _, ing := range rc.Ingredients { + need[ing]++ + } + case "crafting_shaped": + for _, row := range rc.Pattern { + for i := 0; i < len(row); i++ { + sym := string(row[i]) + if sym == " " { + continue + } + if item, ok := rc.Key[sym]; ok { + need[item]++ + } + } + } + } + return need +} + +// PlaceCells 配方填入 3×3 网格(行主序,有序按 pattern 左上对齐;无序顺序铺满)。 +func PlaceCells(rc *Recipe) [9]string { + var cells [9]string + if rc == nil { + return cells + } + if rc.Type == "crafting_shapeless" { + i := 0 + for _, ing := range rc.Ingredients { + if i >= 9 { + break + } + cells[i] = ing + i++ + } + return cells + } + for y, row := range rc.Pattern { + if y >= 3 { + break + } + for x := 0; x < len(row) && x < 3; x++ { + sym := string(row[x]) + if sym == " " { + continue + } + if item, ok := rc.Key[sym]; ok { + cells[y*3+x] = item + } + } + } + return cells +} diff --git a/internal/recipe/recipe_test.go b/internal/recipe/recipe_test.go index 91eb5d74..e26b4d4e 100644 --- a/internal/recipe/recipe_test.go +++ b/internal/recipe/recipe_test.go @@ -59,3 +59,31 @@ func TestMatchSmelting(t *testing.T) { t.Fatal("不存在的熔炼输入不应匹配") } } + +func TestPlaceCellsShaped(t *testing.T) { + r := loadRecipes(t) + var table *Recipe + for _, rc := range r.CraftingRecipes() { + if rc.Result.Item == "crafting_table" { + table = rc + break + } + } + if table == nil { + t.Fatal("缺少工作台配方") + } + need := NeedCounts(table) + if need["oak_planks"] != 4 { + t.Fatalf("工作台应需 4 木板,实际 %v", need) + } + cells := PlaceCells(table) + n := 0 + for _, c := range cells { + if c == "oak_planks" { + n++ + } + } + if n != 4 { + t.Fatalf("填格应有 4 木板,实际 %v", cells) + } +} diff --git a/internal/render/animals_gl.go b/internal/render/animals_gl.go index 7e21811c..1ba84a75 100644 --- a/internal/render/animals_gl.go +++ b/internal/render/animals_gl.go @@ -1,8 +1,6 @@ //go:build gl -// 动物方块模型渲染(渲染.md §5.3):身体/头/腿用方块组合近似原版动物外观, -// 贴图取自实体图集内该动物的纹理格子(子矩形映射到各面)。 -// 与区块共用着色器:alpha test 剔除透明、满亮不受光照影响。 +// 动物方块模型渲染(渲染.md §5.3):盒模型 + 原版像素 UV,朝向 Entity.Yaw。 package render import ( @@ -14,21 +12,7 @@ import ( "github.com/go-gl/gl/v3.3-core/gl" ) -// AnimalPart 动物模型部件(相对脚底原点,模型前向 +z)。 -type AnimalPart struct { - CX, CY, CZ float32 // 部件中心 - W, H, D float32 // 部件尺寸 - // UV 六面在图集 tile 内归一化子矩形(u0,v0,u1,v1),顺序同 cubeFaces: - // 顶/底/东/西/南(前)/北(后) - UV [6][4]float32 -} - -// AnimalModel 动物模型:部件列表。 -type AnimalModel struct { - Parts []AnimalPart -} - -// DrawAnimalModel 在 (x,y,z)(脚底)以 yaw(0=朝 +z)绘制方块动物模型。 +// DrawAnimalModel 在 (x,y,z)(脚底)以 yaw 绘制盒模型(脸在 −Z;Entity.Yaw 已 +π)。 // tile 为实体图集内动物纹理的归一化矩形;部件 UV 相对 tile 定义。 // flash=true 受击白闪(uTint 提亮)。 func (r *Renderer) DrawAnimalModel(x, y, z, yaw float32, model AnimalModel, tile [4]float32, flash bool) { diff --git a/internal/render/mesh_gl.go b/internal/render/mesh_gl.go index 65d71b2a..1740ff8f 100644 --- a/internal/render/mesh_gl.go +++ b/internal/render/mesh_gl.go @@ -67,6 +67,14 @@ func (r *Renderer) UploadChunk(cx, cz int32, m *mesh.ChunkMesh) { r.chunks[k] = cg } +// ClearAllChunks 切换存档时释放全部 GPU 网格。 +func (r *Renderer) ClearAllChunks() { + for k, cg := range r.chunks { + cg.destroy() + delete(r.chunks, k) + } +} + // RemoveChunk 移除区块网格(区块卸载时调用)。 func (r *Renderer) RemoveChunk(cx, cz int32) { k := chunkKey(cx, cz) diff --git a/internal/render/overlay_gl.go b/internal/render/overlay_gl.go index ab2cc328..9a0c3af8 100644 --- a/internal/render/overlay_gl.go +++ b/internal/render/overlay_gl.go @@ -308,3 +308,190 @@ func (r *Renderer) DrawHandItem(uv [4]float32, pos [3]float32, scale float32) { gl.Uniform2f(gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00")), 0, 0) gl.Uniform2f(gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00")), 1, 1) } + +func rotX(p [3]float32, a float32) [3]float32 { + c, s := float32(math.Cos(float64(a))), float32(math.Sin(float64(a))) + return [3]float32{p[0], p[1]*c - p[2]*s, p[1]*s + p[2]*c} +} + +func rotY(p [3]float32, a float32) [3]float32 { + c, s := float32(math.Cos(float64(a))), float32(math.Sin(float64(a))) + return [3]float32{p[0]*c + p[2]*s, p[1], -p[0]*s + p[2]*c} +} + +func rotZ(p [3]float32, a float32) [3]float32 { + c, s := float32(math.Cos(float64(a))), float32(math.Sin(float64(a))) + return [3]float32{p[0]*c - p[1]*s, p[0]*s + p[1]*c, p[2]} +} + +func add3(a, b [3]float32) [3]float32 { return [3]float32{a[0] + b[0], a[1] + b[1], a[2] + b[2]} } + +func swingChop(swing float32) float32 { + if swing <= 0 { + return 0 + } + t := 1 - swing + if t < 0.45 { + return t / 0.45 * 1.15 + } + return (1 - (t-0.45)/0.55) * 1.15 +} + +func mapSkinUV(tile [4]float32, uv [4]float32) [4]float32 { + tw, th := tile[2]-tile[0], tile[3]-tile[1] + return [4]float32{ + tile[0] + uv[0]*tw, tile[1] + uv[1]*th, + tile[0] + uv[2]*tw, tile[1] + uv[3]*th, + } +} + +func appendBox(verts []mesh.Vertex, uvs [6][4]float32, xf func([3]float32) [3]float32) []mesh.Vertex { + for fi, face := range cubeFaces { + u0, v0, u1, v1 := uvs[fi][0], uvs[fi][1], uvs[fi][2], uvs[fi][3] + for _, c := range face { + var cu, cv float32 + switch fi { + case 0, 1: + cu, cv = c[0], c[2] + case 2, 3: + cu, cv = c[2], 1-c[1] + default: + cu, cv = c[0], 1-c[1] + } + p := xf([3]float32{c[0], c[1], c[2]}) + verts = append(verts, mesh.Vertex{ + X: p[0], Y: p[1], Z: p[2], + U: u0 + (u1-u0)*cu, V: v0 + (v1-v0)*cv, + Sky: 15, Block: 15, + }) + } + } + return verts +} + +func (r *Renderer) drawViewMesh(verts []mesh.Vertex, tex uint32, tint [3]float32, cutout float32) { + if len(verts) == 0 { + return + } + if r.animalVao == 0 { + gl.GenVertexArrays(1, &r.animalVao) + gl.GenBuffers(1, &r.animalVbo) + gl.GenBuffers(1, &r.animalIbo) + gl.BindVertexArray(r.animalVao) + gl.BindBuffer(gl.ARRAY_BUFFER, r.animalVbo) + stride := int32(unsafe.Sizeof(mesh.Vertex{})) + gl.EnableVertexAttribArray(0) + gl.VertexAttribPointer(0, 3, gl.FLOAT, false, stride, gl.PtrOffset(0)) + gl.EnableVertexAttribArray(1) + gl.VertexAttribPointer(1, 2, gl.FLOAT, false, stride, gl.PtrOffset(3*4)) + gl.EnableVertexAttribArray(2) + gl.VertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, stride, gl.PtrOffset(5*4)) + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, r.animalIbo) + gl.BindVertexArray(0) + } + idx := make([]uint32, 0, len(verts)/4*6) + for base := 0; base+3 < len(verts); base += 4 { + b := uint32(base) + idx = append(idx, b, b+1, b+2, b+2, b+3, b) + } + gl.BindBuffer(gl.ARRAY_BUFFER, r.animalVbo) + gl.BufferData(gl.ARRAY_BUFFER, len(verts)*int(unsafe.Sizeof(mesh.Vertex{})), gl.Ptr(verts), gl.DYNAMIC_DRAW) + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, r.animalIbo) + gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(idx)*4, gl.Ptr(idx), gl.DYNAMIC_DRAW) + gl.UseProgram(r.prog) + gl.ActiveTexture(gl.TEXTURE0) + if tex != 0 { + gl.BindTexture(gl.TEXTURE_2D, tex) + } + gl.UniformMatrix4fv(gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00")), 1, false, &r.proj[0]) + gl.Uniform3f(gl.GetUniformLocation(r.prog, gl.Str("uOffset\x00")), 0, 0, 0) + gl.Uniform1f(gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00")), cutout) + gl.Uniform4f(gl.GetUniformLocation(r.prog, gl.Str("uTint\x00")), tint[0], tint[1], tint[2], 1) + gl.Uniform2f(gl.GetUniformLocation(r.prog, gl.Str("uBill\x00")), 1, 0) + gl.Uniform2f(gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00")), 0, 0) + gl.Uniform2f(gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00")), 1, 1) + gl.BindVertexArray(r.animalVao) + gl.DrawElements(gl.TRIANGLES, int32(len(idx)), gl.UNSIGNED_INT, gl.PtrOffset(0)) + gl.BindVertexArray(0) +} + +// DrawFirstPerson 第一人称右手:皮肤手臂 + 可选方块/挤出物品,绕肩挥动(渲染.md §5.2)。 +func (r *Renderer) DrawFirstPerson(armTile [4]float32, hasSkin bool, swing float32, blockUVs [6][4]float32, hasBlock bool, itemUV [4]float32, hasItem bool) { + shoulder := [3]float32{0.30, -0.18, -0.50} + chop := swingChop(swing) + xf := func(local [3]float32) [3]float32 { + p := rotZ(rotX(local, 0.55), -0.38) + p = rotX(p, -chop) + return add3(p, shoulder) + } + + armUVs := cuboidUV(40, 16, 4, 12, 4) + if hasSkin { + for i := range armUVs { + armUVs[i] = mapSkinUV(armTile, armUVs[i]) + } + } + armVerts := appendBox(nil, armUVs, func(c [3]float32) [3]float32 { + // 肩在 y=1,指尖朝 -Y;4×12×4 像素 + lx := (c[0] - 0.5) * 0.10 + ly := (c[1] - 1.0) * 0.30 + lz := (c[2] - 0.5) * 0.10 + return xf([3]float32{lx, ly, lz}) + }) + tint := [3]float32{1, 1, 1} + tex := r.entityTex + if !hasSkin { + tint = [3]float32{0.85, 0.65, 0.5} + if tex == 0 { + tex = r.atlas + } + } + r.drawViewMesh(armVerts, tex, tint, 0) + + if hasBlock { + held := appendBox(nil, blockUVs, func(c [3]float32) [3]float32 { + p := [3]float32{(c[0] - 0.5) * 0.16, (c[1] - 0.5) * 0.16, (c[2] - 0.5) * 0.16} + p = rotY(rotX(p, 0.35), -0.55) + p = add3(p, [3]float32{0.02, -0.30, 0.04}) + return xf(p) + }) + r.drawViewMesh(held, r.atlas, [3]float32{1, 1, 1}, 0) + } + if hasItem && r.entityTex != 0 { + u0, v0, u1, v1 := itemUV[0], itemUV[1], itemUV[2], itemUV[3] + thick := float32(0.018) + half := float32(0.11) + corners := [4][2]float32{{-half, -half}, {half, -half}, {half, half}, {-half, half}} + var verts []mesh.Vertex + push := func(x, y, z, u, v float32) { + p := rotY(rotX([3]float32{x, y, z}, 0.25), -0.55) + p = add3(p, [3]float32{0.01, -0.30, 0.03}) + p = xf(p) + verts = append(verts, mesh.Vertex{X: p[0], Y: p[1], Z: p[2], U: u, V: v, Sky: 15, Block: 15}) + } + // 前 + push(corners[0][0], corners[0][1], thick, u0, v1) + push(corners[1][0], corners[1][1], thick, u1, v1) + push(corners[2][0], corners[2][1], thick, u1, v0) + push(corners[3][0], corners[3][1], thick, u0, v0) + // 后 + push(corners[1][0], corners[1][1], -thick, u0, v1) + push(corners[0][0], corners[0][1], -thick, u1, v1) + push(corners[3][0], corners[3][1], -thick, u1, v0) + push(corners[2][0], corners[2][1], -thick, u0, v0) + // 四边挤出(取边缘像素) + edges := [][6]float32{ + {corners[0][0], corners[0][1], corners[1][0], corners[1][1], u0, v1}, + {corners[1][0], corners[1][1], corners[2][0], corners[2][1], u1, (v0 + v1) / 2}, + {corners[2][0], corners[2][1], corners[3][0], corners[3][1], u0, v0}, + {corners[3][0], corners[3][1], corners[0][0], corners[0][1], u0, (v0 + v1) / 2}, + } + for _, e := range edges { + push(e[0], e[1], thick, e[4], e[5]) + push(e[2], e[3], thick, e[4], e[5]) + push(e[2], e[3], -thick, e[4], e[5]) + push(e[0], e[1], -thick, e[4], e[5]) + } + r.drawViewMesh(verts, r.entityTex, [3]float32{1, 1, 1}, 1) + } +} diff --git a/internal/render/renderer.go b/internal/render/renderer.go index f810ab2e..6f293249 100644 --- a/internal/render/renderer.go +++ b/internal/render/renderer.go @@ -48,6 +48,10 @@ type Renderer struct { proj [16]float32 view [16]float32 + + fbW, fbH int // framebuffer 像素(渲染.md §5.4) + fullscr bool + restX, restY, restW, restH int } // chunkGL 一个区块的 GPU 资源。 @@ -71,7 +75,7 @@ func New(title string, width, height int) (*Renderer, error) { glfw.WindowHint(glfw.ContextVersionMajor, 3) glfw.WindowHint(glfw.ContextVersionMinor, 3) glfw.WindowHint(glfw.OpenGLProfile, glfw.OpenGLCoreProfile) - glfw.WindowHint(glfw.Resizable, glfw.False) + glfw.WindowHint(glfw.Resizable, glfw.True) glfw.WindowHint(glfw.Samples, 0) win, err := glfw.CreateWindow(width, height, title, nil, nil) @@ -87,7 +91,26 @@ func New(title string, width, height int) (*Renderer, error) { return nil, fmt.Errorf("render.New gl.Init: %w", err) } - r := &Renderer{window: win, chunks: make(map[uint64]*chunkGL)} + r := &Renderer{window: win, chunks: make(map[uint64]*chunkGL), restW: width, restH: height} + fw, fh := win.GetFramebufferSize() + if fw < 1 { + fw = width + } + if fh < 1 { + fh = height + } + r.fbW, r.fbH = fw, fh + gl.Viewport(0, 0, int32(fw), int32(fh)) + win.SetFramebufferSizeCallback(func(_ *glfw.Window, w, h int) { + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + r.fbW, r.fbH = w, h + gl.Viewport(0, 0, int32(w), int32(h)) + }) if err := r.initShaders(); err != nil { glfw.Terminate() return nil, err @@ -114,9 +137,50 @@ func (r *Renderer) ShouldClose() bool { return r.window.ShouldClose() } // BeginFrame 处理窗口事件并清屏(每帧开始,渲染.md §3.2 主循环)。 func (r *Renderer) BeginFrame() { glfw.PollEvents() + w, h := r.window.GetFramebufferSize() + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + if w != r.fbW || h != r.fbH { + r.fbW, r.fbH = w, h + gl.Viewport(0, 0, int32(w), int32(h)) + } gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } +// FramebufferSize 当前 framebuffer 像素尺寸(渲染.md §5.4)。 +func (r *Renderer) FramebufferSize() (int, int) { return r.fbW, r.fbH } + +// ToggleFullscreen F11 / Alt+Enter 切换独占全屏(渲染.md §5.4)。必须在 GL 线程调用。 +func (r *Renderer) ToggleFullscreen() { + if r.fullscr { + r.window.SetMonitor(nil, r.restX, r.restY, r.restW, r.restH, 0) + r.fullscr = false + return + } + r.restX, r.restY = r.window.GetPos() + r.restW, r.restH = r.window.GetSize() + if r.restW < 1 { + r.restW = 1280 + } + if r.restH < 1 { + r.restH = 720 + } + mon := glfw.GetPrimaryMonitor() + if mon == nil { + return + } + mode := mon.GetVideoMode() + if mode == nil { + return + } + r.window.SetMonitor(mon, 0, 0, mode.Width, mode.Height, mode.RefreshRate) + r.fullscr = true +} + // EndFrame 交换缓冲(每帧结束)。 func (r *Renderer) EndFrame() { r.window.SwapBuffers() } diff --git a/internal/save/region.go b/internal/save/region.go index 2c5708f4..806e8e0d 100644 --- a/internal/save/region.go +++ b/internal/save/region.go @@ -89,60 +89,94 @@ func (r *Region) ReadChunk(cx, cz int32) ([]byte, bool, error) { defer f.Close() raw := make([]byte, count*sectorSize) if _, err := f.ReadAt(raw, int64(sector)*sectorSize); err != nil { - return nil, false, fmt.Errorf("save.ReadChunk 读取扇区: %w", err) + return nil, false, nil // 扇区指针损坏或文件截断:丢弃该区块 } length := binary.BigEndian.Uint32(raw[0:4]) compType := raw[4] - if 5+int(length) > len(raw) { - return nil, false, fmt.Errorf("save.ReadChunk: 记录长度越界") + // 载荷 + 尾部 CRC32;越界视为损坏而非整档失败(存档格式.md §5)。 + if 5+int(length)+4 > len(raw) { + return nil, false, nil } data := raw[5 : 5+int(length)] - wantCRC := binary.BigEndian.Uint32(raw[5+int(length):]) + wantCRC := binary.BigEndian.Uint32(raw[5+int(length) : 5+int(length)+4]) if crc32.Checksum(data, crcTable) != wantCRC { - return nil, false, fmt.Errorf("save.ReadChunk: CRC 校验失败(区块 %d,%d)", cx, cz) + return nil, false, nil } if compType == 0 { return data, true, nil } if compType != 2 { - return nil, false, fmt.Errorf("save.ReadChunk: 未知压缩类型 %d", compType) + return nil, false, nil } out, err := zstdDecode(data) if err != nil { - return nil, false, fmt.Errorf("save.ReadChunk 解压: %w", err) + return nil, false, nil } return out, true, nil } -// WriteChunk 写入区块压缩数据(写完整区域文件到临时文件再原子替换,存档格式.md §6)。 +// WriteChunk 写入区块:重写整个 region(8KB 头 + 紧密扇区),再原子替换。 func (r *Region) WriteChunk(cx, cz int32, data []byte) error { - idx := sectorIndex(cx-r.rx*regionSize, cz-r.rz*regionSize) - comp, err := zstdEncode(data) - if err != nil { - return fmt.Errorf("save.WriteChunk 压缩: %w", err) + chunks := make(map[[2]int32][]byte, 8) + if _, err := os.Stat(r.path); err == nil { + for lz := int32(0); lz < regionSize; lz++ { + for lx := int32(0); lx < regionSize; lx++ { + ccx, ccz := r.rx*regionSize+lx, r.rz*regionSize+lz + raw, ok, err := r.ReadChunk(ccx, ccz) + if err != nil || !ok { + continue + } + chunks[[2]int32{ccx, ccz}] = raw + } + } } - rec := make([]byte, 5+len(comp)+4) - binary.BigEndian.PutUint32(rec[0:4], uint32(len(comp))) - rec[4] = 2 // zstd - copy(rec[5:], comp) - binary.BigEndian.PutUint32(rec[5+len(comp):], crc32.Checksum(comp, crcTable)) + chunks[[2]int32{cx, cz}] = data + return r.rewriteAll(chunks) +} - sectorCount := (len(rec) + sectorSize - 1) / sectorSize - if sectorCount == 0 { - sectorCount = 1 +func (r *Region) rewriteAll(chunks map[[2]int32][]byte) error { + type rec struct { + cx, cz int32 + raw []byte } - // 找空闲扇区(简单策略:追加到文件尾;头部 8KB 占 2 扇区,数据从扇区 2 起) - fileSize := int64(0) - if st, err := os.Stat(r.path); err == nil { - fileSize = st.Size() + list := make([]rec, 0, len(chunks)) + for k, v := range chunks { + list = append(list, rec{k[0], k[1], v}) } - startSector := int(fileSize+sectorSize-1) / sectorSize - if startSector < 2 { - startSector = 2 + var offsets, timestamps [1024]uint32 + sector := 2 + var body []byte + now := uint32(nowUnix()) + for _, it := range list { + comp, err := zstdEncode(it.raw) + if err != nil { + return fmt.Errorf("save.WriteChunk 压缩: %w", err) + } + recb := make([]byte, 5+len(comp)+4) + binary.BigEndian.PutUint32(recb[0:4], uint32(len(comp))) + recb[4] = 2 + copy(recb[5:], comp) + binary.BigEndian.PutUint32(recb[5+len(comp):], crc32.Checksum(comp, crcTable)) + sc := (len(recb) + sectorSize - 1) / sectorSize + if sc < 1 { + sc = 1 + } + pad := sc*sectorSize - len(recb) + idx := sectorIndex(it.cx-r.rx*regionSize, it.cz-r.rz*regionSize) + offsets[idx] = uint32(sector<<8) | uint32(sc) + timestamps[idx] = now + body = append(body, recb...) + if pad > 0 { + body = append(body, make([]byte, pad)...) + } + sector += sc } - // 构建完整新文件:头部 + 既有数据 + 新记录 - tmp, err := os.CreateTemp(filepath.Dir(r.path), "region.tmp.*") + dir := filepath.Dir(r.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "region.tmp.*") if err != nil { return fmt.Errorf("save.WriteChunk 临时文件: %w", err) } @@ -150,33 +184,16 @@ func (r *Region) WriteChunk(cx, cz int32, data []byte) error { defer os.Remove(tmpPath) var head [8192]byte - r.offsets[idx] = uint32(startSector<<8) | uint32(sectorCount) - r.timestamps[idx] = uint32(nowUnix()) for i := 0; i < 1024; i++ { - binary.BigEndian.PutUint32(head[i*4:], r.offsets[i]) - binary.BigEndian.PutUint32(head[4096+i*4:], r.timestamps[i]) + binary.BigEndian.PutUint32(head[i*4:], offsets[i]) + binary.BigEndian.PutUint32(head[4096+i*4:], timestamps[i]) } if _, err := tmp.Write(head[:]); err != nil { tmp.Close() return err } - // 复制旧数据区 - if old, err := os.Open(r.path); err == nil { - if _, err := io.Copy(tmp, old); err != nil { - old.Close() - tmp.Close() - return err - } - old.Close() - } - // 追加新记录(对齐扇区) - if _, err := tmp.Write(rec); err != nil { - tmp.Close() - return err - } - pad := sectorCount*sectorSize - len(rec) - if pad > 0 { - if _, err := tmp.Write(make([]byte, pad)); err != nil { + if len(body) > 0 { + if _, err := tmp.Write(body); err != nil { tmp.Close() return err } @@ -188,10 +205,30 @@ func (r *Region) WriteChunk(cx, cz int32, data []byte) error { if err := tmp.Close(); err != nil { return err } - // 原子替换(存档格式.md §6:临时文件 → rename) - if err := os.Rename(tmpPath, r.path); err != nil { + if err := atomicReplace(tmpPath, r.path); err != nil { return fmt.Errorf("save.WriteChunk 原子替换: %w", err) } + r.offsets = offsets + r.timestamps = timestamps + return nil +} + +// atomicReplace 临时文件 → 目标。Windows 不能 rename 覆盖已存在文件。 +func atomicReplace(tmp, dest string) error { + if err := os.Rename(tmp, dest); err == nil { + return nil + } + bak := dest + ".old" + _ = os.Remove(bak) + if err := os.Rename(dest, bak); err != nil && !os.IsNotExist(err) { + if rmErr := os.Remove(dest); rmErr != nil { + return err + } + } + if err := os.Rename(tmp, dest); err != nil { + return err + } + _ = os.Remove(bak) return nil } diff --git a/internal/save/save.go b/internal/save/save.go index 22324863..817f7805 100644 --- a/internal/save/save.go +++ b/internal/save/save.go @@ -14,17 +14,39 @@ import ( "path/filepath" "mc/internal/block" + "mc/internal/blockentity" "mc/internal/chunk" - "mc/internal/coord" "mc/internal/world" ) +// InvSlot 背包槽快照(存档格式.md §2 player.slots)。 +type InvSlot struct { + Name string `json:"name"` + Count uint8 `json:"count"` + Durability int32 `json:"durability,omitempty"` +} + +// PlayerData 玩家快照(存档格式.md §2)。 +type PlayerData struct { + X, Y, Z float64 `json:"x"` + Yaw, Pitch float64 `json:"yaw"` + Health float64 `json:"health"` + Hunger int `json:"hunger"` + Saturation float64 `json:"saturation,omitempty"` + Selected int `json:"selected"` + Slots []InvSlot `json:"slots,omitempty"` + UnlockedRecipes []string `json:"unlocked_recipes,omitempty"` +} + // LevelData 世界元数据(存档格式.md §2)。 type LevelData struct { - Version int `json:"version"` - Seed int64 `json:"seed"` - Time int64 `json:"time"` - GameMode string `json:"game_mode"` + Version int `json:"version"` + Seed int64 `json:"seed"` + Time int64 `json:"time"` + GameMode string `json:"game_mode"` + Spawn [3]float64 `json:"spawn,omitempty"` + Player *PlayerData `json:"player,omitempty"` + BlockEntities []blockentity.Snapshot `json:"block_entities,omitempty"` } // WriteLevel 写 level.dat(临时文件 + 原子替换)。 @@ -34,6 +56,9 @@ func WriteLevel(dir, name string, lv LevelData) error { return fmt.Errorf("save.WriteLevel: %w", err) } path := filepath.Join(dir, name, "level.dat") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("save.WriteLevel: %w", err) + } tmp, err := os.CreateTemp(filepath.Dir(path), "level.tmp.*") if err != nil { return err @@ -51,7 +76,7 @@ func WriteLevel(dir, name string, lv LevelData) error { if err := tmp.Close(); err != nil { return err } - return os.Rename(tmpPath, path) + return atomicReplace(tmpPath, path) } // ReadLevel 读 level.dat;不存在返回零值。 @@ -117,26 +142,44 @@ func DecodeChunk(data []byte) (*chunk.Chunk, error) { // SaveWorld 保存全部脏区块 + level.dat(存档与持久化.md §2 保存时机由调用方触发)。 func SaveWorld(w *world.World, dir, name string, seed int64, tick int64) error { + return SaveWorldLevel(w, dir, name, LevelData{Version: 1, Seed: seed, Time: tick, GameMode: "survival"}) +} + +// SaveWorldLevel 保存脏区块并写入完整 level.dat(可带玩家快照)。 +func SaveWorldLevel(w *world.World, dir, name string, lv LevelData) error { regionDir := filepath.Join(dir, name, "region") if err := os.MkdirAll(regionDir, 0o755); err != nil { return fmt.Errorf("save.SaveWorld mkdir: %w", err) } + if err := EnqueueDirty(w, nil, regionDir); err != nil { + return err + } + if lv.Version == 0 { + lv.Version = 1 + } + return WriteLevel(dir, name, lv) +} + +// EnqueueDirty 编码脏区块:pool != nil 则异步入队,否则同步写 region。 +func EnqueueDirty(w *world.World, pool *SavePool, regionDir string) error { + if err := os.MkdirAll(regionDir, 0o755); err != nil { + return fmt.Errorf("save.EnqueueDirty mkdir: %w", err) + } for _, c := range w.ActiveChunks() { if !c.SaveDirty.Load() { continue } data := EncodeChunk(c) - rx, rz := coord.FloorDiv(c.CX, regionSize), coord.FloorDiv(c.CZ, regionSize) - region, err := OpenRegion(regionDir, rx, rz) - if err != nil { - return err - } - if err := region.WriteChunk(c.CX, c.CZ, data); err != nil { - return err - } c.SaveDirty.Store(false) + if pool != nil { + pool.Enqueue(ChunkJob{RegionDir: regionDir, CX: c.CX, CZ: c.CZ, Data: data}) + continue + } + if err := writeChunkBytes(regionDir, c.CX, c.CZ, data); err != nil { + return err + } } - return WriteLevel(dir, name, LevelData{Version: 1, Seed: seed, Time: tick, GameMode: "survival"}) + return nil } // LoadWorld 载入世界:level.dat 取 seed 重建,再用 region 中已保存区块覆盖。 @@ -163,21 +206,18 @@ func LoadWorld(w *world.World, dir, name string) (int64, error) { } region, err := OpenRegion(regionDir, rx, rz) if err != nil { - return 0, err + continue // 单个 region 打不开:跳过,不中止整世界 } for lz := int32(0); lz < regionSize; lz++ { for lx := int32(0); lx < regionSize; lx++ { cx, cz := rx*regionSize+lx, rz*regionSize+lz data, ok, err := region.ReadChunk(cx, cz) - if err != nil { - return 0, fmt.Errorf("save.LoadWorld %d,%d: %w", cx, cz, err) - } - if !ok { - continue + if err != nil || !ok { + continue // 损坏记录视为未保存(存档格式.md §5) } c, err := DecodeChunk(data) if err != nil { - return 0, err + continue } w.InstallChunk(c) } diff --git a/internal/save/save_test.go b/internal/save/save_test.go index 6df1f901..6d8dd6a2 100644 --- a/internal/save/save_test.go +++ b/internal/save/save_test.go @@ -1,11 +1,14 @@ package save import ( + "encoding/binary" + "os" "path/filepath" "testing" "time" "mc/internal/block" + "mc/internal/blockentity" "mc/internal/world" "mc/internal/worldgen" ) @@ -87,6 +90,92 @@ func TestRegionWriteRead(t *testing.T) { } } +func TestLoadWorldSkipsCorruptRegion(t *testing.T) { + reg := loadSaveReg(t) + g := newGen(t, reg, 7) + dir := t.TempDir() + const name = "W" + regionDir := filepath.Join(dir, name, "region") + if err := os.MkdirAll(regionDir, 0o755); err != nil { + t.Fatal(err) + } + if err := WriteLevel(dir, name, LevelData{Version: 1, Seed: 7, GameMode: "survival"}); err != nil { + t.Fatal(err) + } + data := EncodeChunk(g.Generate(3, 4)) + region, err := OpenRegion(regionDir, 0, 0) + if err != nil { + t.Fatal(err) + } + if err := region.WriteChunk(3, 4, data); err != nil { + t.Fatal(err) + } + regionPath := filepath.Join(regionDir, "r.0.0.mcr") + raw, err := os.ReadFile(regionPath) + if err != nil { + t.Fatal(err) + } + if len(raw) < 8192+8 { + t.Fatal("region 过短") + } + // 把第一条记录的长度改成巨大值,模拟旧版追加损坏(记录长度越界)。 + binary.BigEndian.PutUint32(raw[8192:8196], 0x7fffffff) + if err := os.WriteFile(regionPath, raw, 0o644); err != nil { + t.Fatal(err) + } + + got, ok, err := region.ReadChunk(3, 4) + if err != nil || ok || got != nil { + t.Fatalf("损坏记录应视为未保存 ok=%v err=%v", ok, err) + } + + w2, err := world.New(reg, g, 3) + if err != nil { + t.Fatal(err) + } + defer w2.Close() + seed, err := LoadWorld(w2, dir, name) + if err != nil { + t.Fatalf("损坏区块不应中止整世界载入: %v", err) + } + if seed != 7 { + t.Fatalf("seed=%d", seed) + } +} + +func TestRegionWriteTwice(t *testing.T) { + reg := loadSaveReg(t) + g := newGen(t, reg, 99) + dir := t.TempDir() + a := EncodeChunk(g.Generate(1, 1)) + b := EncodeChunk(g.Generate(2, 2)) + region, err := OpenRegion(dir, 0, 0) + if err != nil { + t.Fatal(err) + } + if err := region.WriteChunk(1, 1, a); err != nil { + t.Fatal(err) + } + if err := region.WriteChunk(2, 2, b); err != nil { + t.Fatal(err) + } + if err := region.WriteChunk(1, 1, a); err != nil { + t.Fatal(err) + } + region2, err := OpenRegion(dir, 0, 0) + if err != nil { + t.Fatal(err) + } + gotA, ok, err := region2.ReadChunk(1, 1) + if err != nil || !ok || string(gotA) != string(a) { + t.Fatalf("区块 A 往返失败 ok=%v err=%v", ok, err) + } + gotB, ok, err := region2.ReadChunk(2, 2) + if err != nil || !ok || string(gotB) != string(b) { + t.Fatalf("区块 B 往返失败 ok=%v err=%v", ok, err) + } +} + // TestSaveLoadWorld 世界级存取:放火把 → 保存 → 载入 → 火把与光仍在。 func TestSaveLoadWorld(t *testing.T) { reg := loadSaveReg(t) @@ -143,3 +232,127 @@ func TestSaveLoadWorld(t *testing.T) { t.Fatalf("火把光丢失: %d", l) } } + +func TestListCreateDeleteWorld(t *testing.T) { + dir := t.TempDir() + if err := WriteLevel(dir, "Alpha", LevelData{Version: 1, Seed: 1, GameMode: "survival"}); err != nil { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + if err := WriteLevel(dir, "Beta", LevelData{Version: 1, Seed: 2, GameMode: "creative"}); err != nil { + t.Fatal(err) + } + list, err := ListWorlds(dir) + if err != nil || len(list) != 2 { + t.Fatalf("列表 %v err=%v", list, err) + } + if list[0].Name != "Beta" { + t.Fatalf("应按修改时间新→旧,首位 %s", list[0].Name) + } + if UniqueWorldName(dir, "Alpha") != "Alpha-2" { + t.Fatalf("冲突名应为 Alpha-2,实际 %s", UniqueWorldName(dir, "Alpha")) + } + if err := DeleteWorld(dir, "Alpha"); err != nil { + t.Fatal(err) + } + list, _ = ListWorlds(dir) + if len(list) != 1 || list[0].Name != "Beta" { + t.Fatalf("删除后 %v", list) + } + if err := DeleteWorld(dir, "../x"); err == nil { + t.Fatal("非法名应拒绝") + } + if ParseWorldSeed("42") != 42 { + t.Fatal("数字种子") + } + if ParseWorldSeed("") == ParseWorldSeed("abc") && ParseWorldSeed("abc") == 0 { + t.Fatal("空种子与哈希不应都为 0 且相等") + } +} + +func TestLevelBlockEntitiesRoundtrip(t *testing.T) { + dir := t.TempDir() + lv := LevelData{ + Version: 1, Seed: 9, GameMode: "creative", + BlockEntities: []blockentity.Snapshot{{X: 1, Y: 2, Z: 3, Kind: "furnace", CookTicks: 9}}, + Player: &PlayerData{Y: 10, UnlockedRecipes: []string{"stick"}}, + } + if err := WriteLevel(dir, "BE", lv); err != nil { + t.Fatal(err) + } + got, err := ReadLevel(dir, "BE") + if err != nil { + t.Fatal(err) + } + if got.GameMode != "creative" || len(got.BlockEntities) != 1 || got.BlockEntities[0].CookTicks != 9 { + t.Fatalf("level.dat 方块实体往返失败: %+v", got) + } + if got.Player == nil || len(got.Player.UnlockedRecipes) != 1 { + t.Fatalf("解锁配方未写入: %+v", got.Player) + } +} + +func TestSavePool(t *testing.T) { + reg := loadSaveReg(t) + const seed int64 = 7 + g := newGen(t, reg, seed) + w, err := world.New(reg, g, 2) + if err != nil { + t.Fatal(err) + } + defer w.Close() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + w.Update(8, 64, 8, 8*time.Millisecond) + if w.Block(8, 0, 8) != block.Air { + break + } + time.Sleep(2 * time.Millisecond) + } + torch, _ := reg.ID("torch") + w.SetBlock(8, 80, 8, block.NewState(torch, 0)) + dir := t.TempDir() + pool := NewSavePool(nil) + if err := EnqueueDirty(w, pool, filepath.Join(dir, "P", "region")); err != nil { + t.Fatal(err) + } + if err := WriteLevel(dir, "P", LevelData{Version: 1, Seed: seed}); err != nil { + t.Fatal(err) + } + pool.Close() + g2 := newGen(t, reg, seed) + w2, err := world.New(reg, g2, 2) + if err != nil { + t.Fatal(err) + } + defer w2.Close() + if _, err := LoadWorld(w2, dir, "P"); err != nil { + t.Fatal(err) + } + if w2.Block(8, 80, 8).ID() != torch { + t.Fatal("SavePool 写盘后火把应仍在") + } +} + +// TestSavePoolWaitAllowsReuse Wait 只 drain 不关池(存档与持久化.md §4)。 +func TestSavePoolWaitAllowsReuse(t *testing.T) { + reg := loadSaveReg(t) + g := newGen(t, reg, 3) + dir := t.TempDir() + p := NewSavePool(nil) + defer p.Close() + p.Enqueue(ChunkJob{RegionDir: dir, CX: 0, CZ: 0, Data: EncodeChunk(g.Generate(0, 0))}) + p.Wait() + p.Enqueue(ChunkJob{RegionDir: dir, CX: 2, CZ: 0, Data: EncodeChunk(g.Generate(2, 0))}) + p.Wait() + r, err := OpenRegion(dir, 0, 0) + if err != nil { + t.Fatal(err) + } + if _, ok, err := r.ReadChunk(0, 0); err != nil || !ok { + t.Fatalf("第一块应可读 ok=%v err=%v", ok, err) + } + if _, ok, err := r.ReadChunk(2, 0); err != nil || !ok { + t.Fatalf("Wait 后再入队应可读 ok=%v err=%v", ok, err) + } +} diff --git a/internal/ui/crafting.go b/internal/ui/crafting.go index a5f56183..7614e006 100644 --- a/internal/ui/crafting.go +++ b/internal/ui/crafting.go @@ -1,93 +1,142 @@ //go:build gl // 工作台界面(UI还原.md §3.4、工作台与合成表.md §5): -// 176×166 窗口(gui/container/crafting_table.png)+ 3×3 合成网格 (30,17) -// + 输出格 (124,35) + 热键栏行 (7,141);点击槽位与手持栈交换/合并。 +// 176×166 窗口 + 3×3 (30,17) + 输出 (124,35) + 主区 (7,83) + 热键栏 (7,141) +// + 右侧最小配方书。 package ui import ( "fmt" + "strings" "mc/internal/item" + "mc/internal/recipe" ) // CraftingScreen 工作台合成界面。 type CraftingScreen struct { - bgTex uint32 // crafting_table.png(256×256,实际内容 176×166) - items *item.Registry - itemTex uint32 - itemUV map[string][4]float32 - // 方块物品图标回退来源(原版 block/* 图标在 blocks 图集) - blockTex uint32 - blockUV map[string][4]float32 + itemIcons + bgTex uint32 + winW, winH float32 + BookOpen bool + BookQuery string + BookScroll int + OpenT float32 + Labels map[string]string - // 布局(逻辑 427×240 基准,UI还原.md §3.4) - winW, winH float32 // 176×166 + tipTex uint32 + tipW, tipH float32 } // NewCraftingScreen 创建工作台界面。 func NewCraftingScreen(bgTex uint32, items *item.Registry) *CraftingScreen { - return &CraftingScreen{bgTex: bgTex, items: items, - itemUV: map[string][4]float32{}, blockUV: map[string][4]float32{}, winW: 176, winH: 166} + s := &CraftingScreen{bgTex: bgTex, winW: 176, winH: 166, OpenT: 1} + s.items = items + s.itemUV = map[string][4]float32{} + s.blockUV = map[string][4]float32{} + return s } -// SetItemAtlas 设置物品图标纹理与 UV 表(与 HUD 共用 items 图集)。 +// SetItemAtlas 设置物品图标纹理与 UV 表。 func (s *CraftingScreen) SetItemAtlas(tex uint32, uv map[string][4]float32) { - s.itemTex = tex - s.itemUV = uv + s.setItemAtlas(tex, uv) } -// SetBlockAtlas 设置方块图集(方块物品图标回退来源)。 +// SetBlockAtlas 设置方块图集。 func (s *CraftingScreen) SetBlockAtlas(tex uint32, uv map[string][4]float32) { - s.blockTex = tex - s.blockUV = uv + s.setBlockAtlas(tex, uv) +} + +// SetTooltip 设置悬停名称纹理。 +func (s *CraftingScreen) SetTooltip(tex uint32, w, h float32) { + s.tipTex = tex + s.tipW, s.tipH = w, h } -// 合成台窗口槽位(相对窗口左上角,UI还原.md §3.4)。 var craftSlots = [10][2]float32{ {30, 17}, {48, 17}, {66, 17}, {30, 35}, {48, 35}, {66, 35}, - {30, 53}, {48, 53}, {66, 53}, // 3×3 网格 - {124, 35}, // 输出格 + {30, 53}, {48, 53}, {66, 53}, + {124, 35}, } -// HitTest 命中检测:返回 "grid0".."grid8" / "result" / "hot0".."hot8" / ""。 +// HitTest 命中:grid0–8 / result / main0–26 / hot0–8 / book / recipeN。 func (s *CraftingScreen) HitTest(mx, my float32, screenW, screenH, scale float32) string { bx, by := screenW/2-s.winW/2*scale, screenH/2-s.winH/2*scale - hit := func(px, py float32) bool { - return mx >= bx+px*scale && mx < bx+(px+18)*scale && - my >= by+py*scale && my < by+(py+18)*scale - } for i, p := range craftSlots { - if hit(p[0], p[1]) { + if hitSlot(mx, my, bx, by, p[0], p[1], scale) { if i < 9 { return "grid" + string(rune('0'+i)) } return "result" } } - // 热键栏行(窗口内底部,与物品栏窗口同规格) + for i := 0; i < 27; i++ { + px := 7 + float32(i%9)*18 + py := 83 + float32(i/9)*18 + if hitSlot(mx, my, bx, by, px, py, scale) { + return "main" + fmt.Sprintf("%d", i) + } + } for i := 0; i < 9; i++ { - if hit(7+float32(i)*18, 141) { - return "hot" + string(rune('0'+i)) + if hitSlot(mx, my, bx, by, 7+float32(i)*18, 141, scale) { + return "hot" + fmt.Sprintf("%d", i) + } + } + // 配方书按钮:窗口右侧 + if mx >= bx+(s.winW+2)*scale && mx < bx+(s.winW+20)*scale && + my >= by+8*scale && my < by+26*scale { + return "book" + } + if s.BookOpen { + if mx >= bx+(s.winW+22)*scale && mx < bx+(s.winW+100)*scale && + my >= by && my < by+16*scale { + return "search" + } + for i := 0; i < 8; i++ { + px := s.winW + 22 + py := 20 + float32(i)*16 + if hitSlot(mx, my, bx, by, px, py, scale) { + return "recipe" + fmt.Sprintf("%d", s.BookScroll+i) + } } } return "" } -// Draw 绘制工作台界面(调用方已 Begin;mx/my 为鼠标像素坐标,held 为手持栈)。 -func (s *CraftingScreen) Draw(r *Renderer, grid [9]item.Stack, result item.Stack, hasResult bool, hotbar []item.Stack, held item.Stack, mx, my, scale float32) { +// FilterRecipes 按搜索过滤。 +func FilterRecipes(all []*recipe.Recipe, q string, labels map[string]string) []*recipe.Recipe { + q = strings.ToLower(strings.TrimSpace(q)) + if q == "" { + return all + } + var out []*recipe.Recipe + for _, rc := range all { + if rc == nil { + continue + } + if strings.Contains(strings.ToLower(rc.Result.Item), q) { + out = append(out, rc) + continue + } + if labels != nil && strings.Contains(strings.ToLower(labels[rc.Result.Item]), q) { + out = append(out, rc) + } + } + return out +} + +// Draw 绘制工作台(slots 为完整背包;recipes 为配方书条目)。 +func (s *CraftingScreen) Draw(r *Renderer, grid [9]item.Stack, result item.Stack, hasResult bool, slots []item.Stack, held item.Stack, recipes []*recipe.Recipe, unlocked map[string]bool, mx, my, scale float32) { w, h := float32(r.w), float32(r.h) - // 半透明遮罩(原版容器界面背景压暗) r.DrawRect(0, 0, w, h, [4]float32{0, 0, 0, 0.35}) - // 窗口背景(纹理 256×256,内容区 176×166,UI还原.md §3.2) - bx, by := w/2-s.winW/2*scale, h/2-s.winH/2*scale + sc := scale * (0.85 + 0.15*s.OpenT) + bx, by := w/2-s.winW/2*sc, h/2-s.winH/2*sc if s.bgTex != 0 { - r.pushQuad(quad{x: bx, y: by, w: s.winW * scale, h: s.winH * scale, + r.pushQuad(quad{x: bx, y: by, w: s.winW * sc, h: s.winH * sc, u0: 0, v0: 0, u1: 176.0 / 256.0, v1: 166.0 / 256.0, r: 1, g: 1, b: 1, a: 1, tex: s.bgTex}) } - // 3×3 网格 + 输出格 for i, p := range craftSlots { var st item.Stack if i < 9 { @@ -95,40 +144,72 @@ func (s *CraftingScreen) Draw(r *Renderer, grid [9]item.Stack, result item.Stack } else if hasResult { st = result } - s.drawSlot(r, st, bx+p[0]*scale+1*scale, by+p[1]*scale+1*scale, scale) + s.drawSlot(r, st, bx+p[0]*sc+1*sc, by+p[1]*sc+1*sc, sc) } - // 热键栏行 - for i := 0; i < 9 && i < len(hotbar); i++ { - s.drawSlot(r, hotbar[i], bx+(7+float32(i)*18)*scale+1*scale, by+141*scale+1*scale, scale) + for i := 0; i < 27; i++ { + idx := 9 + i + st := item.Stack{} + if idx < len(slots) { + st = slots[idx] + } + px := 7 + float32(i%9)*18 + py := 83 + float32(i/9)*18 + s.drawSlot(r, st, bx+(px+1)*sc, by+(py+1)*sc, sc) + } + for i := 0; i < 9 && i < len(slots); i++ { + s.drawSlot(r, slots[i], bx+(7+float32(i)*18)*sc+1*sc, by+141*sc+1*sc, sc) + } + btn := [4]float32{0.55, 0.45, 0.25, 1} + if s.BookOpen { + btn = [4]float32{0.75, 0.65, 0.35, 1} + } + r.DrawRect(bx+(s.winW+2)*sc, by+8*sc, 18*sc, 18*sc, btn) + r.DrawText(bx+(s.winW+6)*sc, by+13*sc, "R", sc, [4]float32{1, 1, 0.85, 1}) + if s.BookOpen { + r.DrawRect(bx+(s.winW+20)*sc, by*1, 102*sc, 160*sc, [4]float32{0.12, 0.12, 0.12, 0.88}) + r.DrawRect(bx+(s.winW+22)*sc, by+2*sc, 78*sc, 14*sc, [4]float32{0, 0, 0, 0.55}) + qs := s.BookQuery + if qs == "" { + qs = "search" + } + r.DrawText(bx+(s.winW+24)*sc, by+5*sc, qs, 0.65*sc, [4]float32{0.85, 0.85, 0.85, 1}) + vis := FilterRecipes(recipes, s.BookQuery, s.Labels) + if s.BookScroll < 0 { + s.BookScroll = 0 + } + n := 8 + if len(vis)-s.BookScroll < n { + n = len(vis) - s.BookScroll + } + if n < 0 { + n = 0 + } + for i := 0; i < n; i++ { + rc := vis[s.BookScroll+i] + py := 20 + float32(i)*16 + st := item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))} + s.drawSlot(r, st, bx+(s.winW+23)*sc, by+(py+1)*sc, sc) + col := [4]float32{1, 1, 1, 1} + if unlocked != nil && !unlocked[rc.Result.Item] { + col = [4]float32{0.45, 0.45, 0.45, 1} + } + r.DrawText(bx+(s.winW+42)*sc, by+(py+4)*sc, rc.Result.Item, 0.6*sc, col) + } } - // 手持栈跟随鼠标 if !held.Empty() { s.drawSlot(r, held, mx-8*scale, my-8*scale, scale) } + if s.tipTex != 0 && s.tipW > 0 { + tx, ty := mx+12, my+12 + drawTooltipBG(r, tx, ty, s.tipW, s.tipH) + r.pushQuad(quad{x: tx, y: ty, w: s.tipW, h: s.tipH, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.tipTex}) + } } -// drawSlot 在指定位置绘制物品图标(16×16 逻辑像素)。 -func (s *CraftingScreen) drawSlot(r *Renderer, st item.Stack, x, y, scale float32) { - if st.Empty() || s.items == nil { - return - } - def, ok := s.items.Get(st.Name) - if !ok { - return - } - // 原版约定:block/* 图标在 blocks 图集,item/* 在 items 图集 - tex := s.itemTex - uv, found := s.itemUV[def.Texture] - if !found { - uv, found = s.blockUV[def.Texture] - tex = s.blockTex - } - if found && tex != 0 { - r.pushQuad(quad{x: x, y: y, w: 16 * scale, h: 16 * scale, - u0: uv[0], v0: uv[1], u1: uv[2], v1: uv[3], - r: 1, g: 1, b: 1, a: 1, tex: tex}) - } - if st.Count > 1 { - r.DrawText(x+10*scale, y+8*scale, fmt.Sprintf("%d", st.Count), scale, [4]float32{1, 1, 1, 1}) +func min(a, b int) int { + if a < b { + return a } + return b } diff --git a/internal/ui/hud.go b/internal/ui/hud.go index 0790a539..db0479fc 100644 --- a/internal/ui/hud.go +++ b/internal/ui/hud.go @@ -5,9 +5,11 @@ package ui import ( "fmt" + "math" "mc/internal/game" "mc/internal/item" + "mc/internal/physics" ) // HUD 游戏内 HUD。 @@ -30,12 +32,20 @@ type HUD struct { heartCont uint32 // heart/container.png foodFull uint32 // food_full.png 9×9 foodHalf uint32 // food_half.png + airFull uint32 // air.png 9×9 + airBurst uint32 // air_bursting.png haveSprites bool - // 准星指向方块名(左下角展示,UI还原.md §3.4) - targetTex uint32 - targetW float32 - targetH float32 + // 准星识别条(左下角:图标+名称+暗底,UI还原.md §3.4) + targetTex uint32 + targetW float32 + targetH float32 + targetBlock string + + // 附近音效字幕(识别条下方) + captionTex []uint32 + captionW []float32 + captionH []float32 // 小地图(左上角,UI还原.md §3.5) minimapTex uint32 @@ -49,6 +59,9 @@ type HUD struct { // OnlineCount 在线玩家数(联机时由调用方注入;nil 表示单机不显示) OnlineCount func() int + + punchT float32 + punchSlot int } // NewHUD 创建 HUD。 @@ -56,6 +69,9 @@ func NewHUD(sess *game.Session, items *item.Registry, baseW, baseH, scale float3 return &HUD{sess: sess, items: items, itemUV: map[string][4]float32{}, blockUV: map[string][4]float32{}, baseW: baseW, baseH: baseH, scale: scale} } +// SetSession 切换存档后换会话。 +func (h *HUD) SetSession(sess *game.Session) { h.sess = sess } + // SetItemAtlas 设置物品图标纹理与 UV 表(items 图集)。 func (h *HUD) SetItemAtlas(tex uint32, uv map[string][4]float32) { h.itemTex = tex @@ -77,9 +93,19 @@ func (h *HUD) SetSprites(cross, hotbar, hotbarSel, heartFull, heartHalf, heartCo h.haveSprites = cross != 0 } -// SetTarget 设置准星指向的方块名标签纹理(渲染线程注入;tex=0 清除)。 -func (h *HUD) SetTarget(tex uint32, w, hgt float32) { - h.targetTex, h.targetW, h.targetH = tex, w, hgt +// SetAirSprites 呼吸气泡(UI还原.md §3.4)。 +func (h *HUD) SetAirSprites(full, burst uint32) { + h.airFull, h.airBurst = full, burst +} + +// SetTarget 设置准星识别条(名称纹理 + 方块注册名;tex=0 清除)。 +func (h *HUD) SetTarget(tex uint32, w, hgt float32, blockName string) { + h.targetTex, h.targetW, h.targetH, h.targetBlock = tex, w, hgt, blockName +} + +// SetCaptions 设置附近音效字幕行纹理(渲染线程注入)。 +func (h *HUD) SetCaptions(tex []uint32, w, hgt []float32) { + h.captionTex, h.captionW, h.captionH = tex, w, hgt } // SetMinimap 设置小地图纹理(渲染线程注入;tex=0 清除)。 @@ -88,6 +114,66 @@ func (h *HUD) SetMinimap(tex uint32) { h.minimapTex = tex } // SetUnderwater 设置水下状态(蓝色叠加与雾效)。 func (h *HUD) SetUnderwater(b bool) { h.underwater = b } +// SetScale 更新整数 guiScale(窗口缩放后,UI还原.md §3.1)。 +func (h *HUD) SetScale(scale float32) { + if scale < 1 { + scale = 1 + } + h.scale = scale +} + +// Punch 热键栏切槽弹出缩回(约 150ms,1.4× → 1.0×)。 +func (h *HUD) Punch(slot int) { + h.punchSlot = slot + h.punchT = 1 +} + +// TickAnims 衰减弹出。 +func (h *HUD) TickAnims(dt float32) { + if h.punchT > 0 { + h.punchT -= dt / 0.15 + if h.punchT < 0 { + h.punchT = 0 + } + } +} + +// HotbarIconPos 热键栏槽中心(屏幕像素)。 +func (h *HUD) HotbarIconPos(slot int, screenW, screenH float32) (x, y float32) { + s := h.scale + barW, barH := 182*s, 22*s + bx, by := screenW/2-barW/2, screenH-barH + if slot < 0 { + slot = 0 + } + if slot > 8 { + slot = 8 + } + return bx + float32(slot)*20*s + 3*s + 8*s, by + 3*s + 8*s +} + +// DrawIconAt 在任意位置画物品图标(拾取飞入)。 +func (h *HUD) DrawIconAt(r *Renderer, name string, x, y, size float32) { + if name == "" || h.items == nil { + return + } + def, ok := h.items.Get(name) + if !ok { + return + } + tex := h.itemTex + uv, found := h.itemUV[def.Texture] + if !found { + uv, found = h.blockUV[def.Texture] + tex = h.blockTex + } + if found && tex != 0 { + r.pushQuad(quad{x: x - size/2, y: y - size/2, w: size, h: size, + u0: uv[0], v0: uv[1], u1: uv[2], v1: uv[3], + r: 1, g: 1, b: 1, a: 1, tex: tex}) + } +} + // Draw 每帧绘制 HUD(调用方已 Begin)。 func (h *HUD) Draw(r *Renderer) { w, hgt := float32(r.w), float32(r.h) @@ -124,7 +210,7 @@ func (h *HUD) Draw(r *Renderer) { r.DrawRect(bx+float32(i)*20*s+1*s, by+3*s, 20*s, 20*s, [4]float32{1, 1, 1, 0.9}) } } - h.drawItemIcon(r, h.sess.Inv.Slots[i], bx+float32(i)*20*s+3*s, by+3*s) + h.drawItemIcon(r, h.sess.Inv.Slots[i], bx+float32(i)*20*s+3*s, by+3*s, i) } // 生命:10 心(原版 9×9 图标,屏幕左下,UI还原.md §3.4 布局: @@ -159,7 +245,7 @@ func (h *HUD) Draw(r *Renderer) { } // 饥饿:10 鸡腿(原版 food_full,屏幕**右下**——血条左、饥饿右的经典布局) - food := 20 // 饥饿值(饥饿系统后置,恒定 20) + food := h.sess.PlayerHunger() fy := hgt - 39*s fx := w/2 + 11*s // 与左侧生命行镜像对称(w/2+11 .. w/2+91) for i := 0; i < 10; i++ { @@ -180,6 +266,27 @@ func (h *HUD) Draw(r *Renderer) { } } + // 呼吸气泡:眼睛在水时画在饥饿行上方(UI还原.md §3.4) + if h.sess.Player.EyeInWater || h.sess.PlayerAir() < physics.AirMax-0.01 { + bubbles := int(math.Ceil(h.sess.PlayerAir() / 1.5)) // 15s / 10 格 + if bubbles > 10 { + bubbles = 10 + } + ay := hgt - 49*s + for i := 0; i < 10; i++ { + x := fx + float32(i)*8*s + if i < bubbles { + if h.haveSprites && h.airFull != 0 { + h.blit(r, h.airFull, x, ay, 9*s, 9*s) + } else { + r.DrawRect(x, ay, 7*s, 7*s, [4]float32{0.7, 0.85, 1, 0.9}) + } + } else if h.haveSprites && h.airBurst != 0 && i == bubbles { + h.blit(r, h.airBurst, x, ay, 9*s, 9*s) + } + } + } + // 破坏进度(方块交互与动画.md §4:3D 裂纹由渲染层叠加,HUD 不画进度条) // 坐标调试(后置移除) @@ -188,9 +295,34 @@ func (h *HUD) Draw(r *Renderer) { if h.OnlineCount != nil { r.DrawText(w/2, hgt-82*s, fmt.Sprintf("在线玩家: %d", h.OnlineCount()), 1*s, [4]float32{1, 1, 1, 1}) } - // 准星指向的方块名(左下角,生命行上方,UI还原.md §3.4) - if h.targetTex != 0 { - h.blit(r, h.targetTex, 2*s, hgt-96*s-h.targetH, h.targetW, h.targetH) + // 准星识别条 + 附近音效字幕(UI还原.md §3.4) + baseY := hgt - 96*s + if h.targetTex != 0 || h.targetBlock != "" { + pad, icon, gap := 3*s, 16*s, 4*s + barH := icon + pad*2 + if h.targetH+pad*2 > barH { + barH = h.targetH + pad*2 + } + barW := pad + icon + gap + h.targetW + pad + if barW < 40*s { + barW = 40 * s + } + r.DrawRect(2*s, baseY-barH, barW, barH, [4]float32{0, 0, 0, 0.62}) + h.drawItemIcon(r, item.Stack{Name: h.targetBlock, Count: 1}, 2*s+pad, baseY-barH+pad, -1) + if h.targetTex != 0 { + ty := baseY - barH + (barH-h.targetH)/2 + h.blit(r, h.targetTex, 2*s+pad+icon+gap, ty, h.targetW, h.targetH) + } + } + capY := baseY + 1*s + for i := range h.captionTex { + if h.captionTex[i] == 0 { + continue + } + cw, ch := h.captionW[i], h.captionH[i] + r.DrawRect(2*s, capY, cw+6*s, ch+2*s, [4]float32{0, 0, 0, 0.45}) + h.blit(r, h.captionTex[i], 5*s, capY+1*s, cw, ch) + capY += ch + 3*s } // 小地图(左上角 96×96 逻辑像素 + 边框;玩家标记已烘焙进地图图—— // 覆盖层 quad 在批处理末尾存在颜色异常,见 updateMinimap drawMarker) @@ -214,7 +346,7 @@ func (h *HUD) blit(r *Renderer, tex uint32, x, y, w, hgt float32) { } // drawItemIcon 在槽位绘制物品图标 + 数量。 -func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) { +func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32, slot int) { s := h.scale if stack.Empty() { return @@ -223,6 +355,12 @@ func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) { if !ok { return } + sc := s + if slot >= 0 && slot == h.punchSlot && h.punchT > 0 { + sc = s * (1 + 0.4*h.punchT) + x -= (16*sc - 16*s) / 2 + y -= (16*sc - 16*s) / 2 + } // 原版约定:block/* 图标在 blocks 图集,item/* 在 items 图集 tex := h.itemTex uv, found := h.itemUV[def.Texture] @@ -231,11 +369,12 @@ func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) { tex = h.blockTex } if found && tex != 0 { - r.pushQuad(quad{x: x, y: y, w: 16 * s, h: 16 * s, + r.pushQuad(quad{x: x, y: y, w: 16 * sc, h: 16 * sc, u0: uv[0], v0: uv[1], u1: uv[2], v1: uv[3], r: 1, g: 1, b: 1, a: 1, tex: tex}) } if stack.Count > 1 { - r.DrawText(x+10*s, y+8*s, fmt.Sprintf("%d", stack.Count), 1*s, [4]float32{1, 1, 1, 1}) + r.DrawText(x+10*sc, y+8*sc, fmt.Sprintf("%d", stack.Count), 1*s, [4]float32{1, 1, 1, 1}) } + drawDurabilityBar(r, stack, def, x, y, sc) } diff --git a/internal/ui/inventory.go b/internal/ui/inventory.go index 5f7c0f6e..441b7a07 100644 --- a/internal/ui/inventory.go +++ b/internal/ui/inventory.go @@ -1,8 +1,8 @@ //go:build gl -// 物品栏界面(UI还原.md §3.3、物品与背包.md §4): -// inventory.png 176×166 窗口 + 27 主物品区 (7,83) + 热键栏行 (7,141); -// 点击槽位与手持栈交换/合并;E 键打开、Esc 关闭。 +// 物品栏界面(UI还原.md §3.3、物品与背包.md §3): +// inventory.png 176×166 + 27 主区 (7,83) + 热键栏 (7,141) + 盔甲 4 (7,8) +// + 副手 (77,8) + 2×2 (98,8) + 输出 (125,24) + 玩家模型 (34,9)–(69,80)。 package ui import ( @@ -13,106 +13,143 @@ import ( // InventoryScreen 物品栏界面。 type InventoryScreen struct { - bgTex uint32 // inventory.png(256×256,内容 176×166) - items *item.Registry - itemTex uint32 - itemUV map[string][4]float32 - // 方块物品图标回退来源(原版 block/* 图标在 blocks 图集) - blockTex uint32 - blockUV map[string][4]float32 + itemIcons + bgTex uint32 + winW, winH float32 - winW, winH float32 // 176×166 + tipTex uint32 + tipW, tipH float32 + OpenT float32 } // NewInventoryScreen 创建物品栏界面。 func NewInventoryScreen(bgTex uint32, items *item.Registry) *InventoryScreen { - return &InventoryScreen{bgTex: bgTex, items: items, - itemUV: map[string][4]float32{}, blockUV: map[string][4]float32{}, winW: 176, winH: 166} + s := &InventoryScreen{bgTex: bgTex, winW: 176, winH: 166, OpenT: 1} + s.items = items + s.itemUV = map[string][4]float32{} + s.blockUV = map[string][4]float32{} + return s } // SetItemAtlas 设置 items 图集。 func (s *InventoryScreen) SetItemAtlas(tex uint32, uv map[string][4]float32) { - s.itemTex = tex - s.itemUV = uv + s.setItemAtlas(tex, uv) } -// SetBlockAtlas 设置 blocks 图集(方块物品图标回退)。 +// SetBlockAtlas 设置 blocks 图集。 func (s *InventoryScreen) SetBlockAtlas(tex uint32, uv map[string][4]float32) { - s.blockTex = tex - s.blockUV = uv + s.setBlockAtlas(tex, uv) } -// HitTest 命中检测:返回 "main0".."main26" / "hot0".."hot8" / ""。 -// 主物品区 3×9 自 (7,83),热键栏 1×9 自 (7,141)(UI还原.md §3.3)。 +// SetTooltip 设置悬停名称纹理(tick 光栅化 CJK,渲染线程上传)。 +func (s *InventoryScreen) SetTooltip(tex uint32, w, h float32) { + s.tipTex = tex + s.tipW, s.tipH = w, h +} + +// HitTest 命中:main0–26 / hot0–8 / armor0–3 / offhand / craft0–3 / result。 func (s *InventoryScreen) HitTest(mx, my float32, screenW, screenH, scale float32) string { bx, by := screenW/2-s.winW/2*scale, screenH/2-s.winH/2*scale - hit := func(px, py float32) bool { - return mx >= bx+px*scale && mx < bx+(px+18)*scale && - my >= by+py*scale && my < by+(py+18)*scale + for i := 0; i < 4; i++ { + if hitSlot(mx, my, bx, by, 7, 8+float32(i)*18, scale) { + return "armor" + fmt.Sprintf("%d", i) + } + } + if hitSlot(mx, my, bx, by, 77, 8, scale) { + return "offhand" + } + for i := 0; i < 4; i++ { + px := 98 + float32(i%2)*18 + py := 8 + float32(i/2)*18 + if hitSlot(mx, my, bx, by, px, py, scale) { + return "craft" + fmt.Sprintf("%d", i) + } + } + if hitSlot(mx, my, bx, by, 125, 24, scale) { + return "result" } for i := 0; i < 27; i++ { px := 7 + float32(i%9)*18 py := 83 + float32(i/9)*18 - if hit(px, py) { + if hitSlot(mx, my, bx, by, px, py, scale) { return "main" + fmt.Sprintf("%d", i) } } for i := 0; i < 9; i++ { - if hit(7+float32(i)*18, 141) { + if hitSlot(mx, my, bx, by, 7+float32(i)*18, 141, scale) { return "hot" + fmt.Sprintf("%d", i) } } return "" } -// Draw 绘制物品栏界面(调用方已 Begin)。 -func (s *InventoryScreen) Draw(r *Renderer, slots []item.Stack, held item.Stack, mx, my, scale float32) { +// Draw 绘制物品栏(slots 含盔甲/副手;craft 为 2×2;res 为输出)。 +func (s *InventoryScreen) Draw(r *Renderer, slots []item.Stack, craft [4]item.Stack, res item.Stack, hasRes bool, held item.Stack, mx, my, scale float32) { w, h := float32(r.w), float32(r.h) r.DrawRect(0, 0, w, h, [4]float32{0, 0, 0, 0.35}) - bx, by := w/2-s.winW/2*scale, h/2-s.winH/2*scale + sc := scale * (0.85 + 0.15*s.OpenT) + bx, by := w/2-s.winW/2*sc, h/2-s.winH/2*sc if s.bgTex != 0 { - r.pushQuad(quad{x: bx, y: by, w: s.winW * scale, h: s.winH * scale, + r.pushQuad(quad{x: bx, y: by, w: s.winW * sc, h: s.winH * sc, u0: 0, v0: 0, u1: 176.0 / 256.0, v1: 166.0 / 256.0, r: 1, g: 1, b: 1, a: 1, tex: s.bgTex}) } - // 27 主物品区 - for i := 0; i < 27 && i < len(slots); i++ { + s.drawPlayerModel(r, bx, by, sc) + // 盔甲 4 + for i := 0; i < 4; i++ { + idx := 36 + i + st := item.Stack{} + if idx < len(slots) { + st = slots[idx] + } + s.drawSlot(r, st, bx+(7+1)*sc, by+(8+float32(i)*18+1)*sc, sc) + } + if 40 < len(slots) { + s.drawSlot(r, slots[40], bx+(77+1)*sc, by+(8+1)*sc, sc) + } + for i := 0; i < 4; i++ { + px := 98 + float32(i%2)*18 + py := 8 + float32(i/2)*18 + s.drawSlot(r, craft[i], bx+(px+1)*sc, by+(py+1)*sc, sc) + } + if hasRes { + s.drawSlot(r, res, bx+(125+1)*sc, by+(24+1)*sc, sc) + } + for i := 0; i < 27; i++ { + idx := 9 + i + st := item.Stack{} + if idx < len(slots) { + st = slots[idx] + } px := 7 + float32(i%9)*18 py := 83 + float32(i/9)*18 - s.drawSlot(r, slots[i], bx+(px+1)*scale, by+(py+1)*scale, scale) + s.drawSlot(r, st, bx+(px+1)*sc, by+(py+1)*sc, sc) } - // 热键栏行 - for i := 0; i < 9 && 9+i < len(slots); i++ { - px := 7 + float32(i)*18 - s.drawSlot(r, slots[9+i], bx+(px+1)*scale, by+(141+1)*scale, scale) + for i := 0; i < 9 && i < len(slots); i++ { + s.drawSlot(r, slots[i], bx+(7+float32(i)*18+1)*sc, by+(141+1)*sc, sc) } - // 手持栈跟随鼠标 if !held.Empty() { s.drawSlot(r, held, mx-8*scale, my-8*scale, scale) } + if s.tipTex != 0 && s.tipW > 0 { + tx, ty := mx+12, my+12 + drawTooltipBG(r, tx, ty, s.tipW, s.tipH) + r.pushQuad(quad{x: tx, y: ty, w: s.tipW, h: s.tipH, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.tipTex}) + } } -// drawSlot 绘制物品图标 + 数量(16×16 逻辑像素)。 -func (s *InventoryScreen) drawSlot(r *Renderer, st item.Stack, x, y, scale float32) { - if st.Empty() || s.items == nil { - return - } - def, ok := s.items.Get(st.Name) - if !ok { - return - } - tex := s.itemTex - uv, found := s.itemUV[def.Texture] - if !found { - uv, found = s.blockUV[def.Texture] - tex = s.blockTex - } - if found && tex != 0 { - r.pushQuad(quad{x: x, y: y, w: 16 * scale, h: 16 * scale, - u0: uv[0], v0: uv[1], u1: uv[2], v1: uv[3], - r: 1, g: 1, b: 1, a: 1, tex: tex}) - } - if st.Count > 1 { - r.DrawText(x+10*scale, y+8*scale, fmt.Sprintf("%d", st.Count), scale, [4]float32{1, 1, 1, 1}) - } +func (s *InventoryScreen) drawPlayerModel(r *Renderer, bx, by, scale float32) { + // 简化纸片人,区域 (34,9)–(69,80) + ox, oy := bx+34*scale, by+9*scale + skin := [4]float32{0.85, 0.68, 0.52, 1} + shirt := [4]float32{0.25, 0.35, 0.75, 1} + pants := [4]float32{0.22, 0.22, 0.55, 1} + // 头 8×8、身 8×12、臂 4×12、腿 4×12(逻辑像素) + r.DrawRect(ox+10*scale, oy+2*scale, 8*scale, 8*scale, skin) + r.DrawRect(ox+10*scale, oy+10*scale, 8*scale, 12*scale, shirt) + r.DrawRect(ox+6*scale, oy+10*scale, 4*scale, 12*scale, shirt) + r.DrawRect(ox+18*scale, oy+10*scale, 4*scale, 12*scale, shirt) + r.DrawRect(ox+10*scale, oy+22*scale, 4*scale, 12*scale, pants) + r.DrawRect(ox+14*scale, oy+22*scale, 4*scale, 12*scale, pants) } diff --git a/internal/ui/options.go b/internal/ui/options.go index d26f44dd..d7a062a7 100644 --- a/internal/ui/options.go +++ b/internal/ui/options.go @@ -33,6 +33,8 @@ type OptionsScreen struct { DoneLabelTex uint32 DoneLabelW float32 DoneLabelH float32 + + ox float32 // 相对 1280 基准的水平偏移(窗口居中) } // 滑条区域(屏幕像素,1280×720 布局)。 @@ -42,24 +44,35 @@ var ( optDone = [4]float32{540, 560, 200, 20} ) +// Layout 按窗口宽度水平居中控件(相对 1280 基准)。 +func (s *OptionsScreen) Layout(winW float32) { + s.ox = winW/2 - 640 +} + +func (s *OptionsScreen) bar(base [4]float32) [4]float32 { + return [4]float32{base[0] + s.ox, base[1], base[2], base[3]} +} + // HitTest 命中检测:返回 "sens-"/"sens+"/"vol-"/"vol+"/"done"/""。 func (s *OptionsScreen) HitTest(mx, my float32) string { hit := func(r [4]float32) bool { return mx >= r[0] && mx < r[0]+r[2] && my >= r[1] && my < r[1]+r[3] } - if hit(optSensBar) { - if mx < optSensBar[0]+optSensBar[2]/2 { + sens := s.bar(optSensBar) + if hit(sens) { + if mx < sens[0]+sens[2]/2 { return "sens-" } return "sens+" } - if hit(optVolBar) { - if mx < optVolBar[0]+optVolBar[2]/2 { + vol := s.bar(optVolBar) + if hit(vol) { + if mx < vol[0]+vol[2]/2 { return "vol-" } return "vol+" } - if hit(optDone) { + if hit(s.bar(optDone)) { return "done" } return "" @@ -75,27 +88,28 @@ func (s *OptionsScreen) Draw(r *Renderer, mx, my, sens, vol float32) { u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.TitleTex}) } // 标签 - s.blitLabel(r, s.SensLabelTex, s.SensLabelW, s.SensLabelH, 380, 255) - s.blitLabel(r, s.VolLabelTex, s.VolLabelW, s.VolLabelH, 380, 325) + s.blitLabel(r, s.SensLabelTex, s.SensLabelW, s.SensLabelH, 380+s.ox, 255) + s.blitLabel(r, s.VolLabelTex, s.VolLabelW, s.VolLabelH, 380+s.ox, 325) // 滑条(底 + 填充 + 值) - s.drawSlider(r, optSensBar, sens) - s.drawSlider(r, optVolBar, vol) + s.drawSlider(r, s.bar(optSensBar), sens) + s.drawSlider(r, s.bar(optVolBar), vol) // 世界种子 - s.blitLabel(r, s.SeedLabelTex, s.SeedLabelW, s.SeedLabelH, 380, 395) + s.blitLabel(r, s.SeedLabelTex, s.SeedLabelW, s.SeedLabelH, 380+s.ox, 395) // 按键教程列表 - s.blitLabel(r, s.ControlsTex, s.ControlsW, s.ControlsH, 380, 435) + s.blitLabel(r, s.ControlsTex, s.ControlsW, s.ControlsH, 380+s.ox, 435) // 完成按钮 - hover := mx >= optDone[0] && mx < optDone[0]+optDone[2] && my >= optDone[1] && my < optDone[1]+optDone[3] + done := s.bar(optDone) + hover := mx >= done[0] && mx < done[0]+done[2] && my >= done[1] && my < done[1]+done[3] tex := s.BtnTex if hover && s.BtnHover != 0 { tex = s.BtnHover } if tex != 0 { - r.pushQuad(quad{x: optDone[0], y: optDone[1], w: optDone[2], h: optDone[3], + r.pushQuad(quad{x: done[0], y: done[1], w: done[2], h: done[3], u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: tex}) } if s.DoneLabelTex != 0 { - r.pushQuad(quad{x: optDone[0] + (optDone[2]-s.DoneLabelW)/2, y: optDone[1] + (optDone[3]-s.DoneLabelH)/2, + r.pushQuad(quad{x: done[0] + (done[2]-s.DoneLabelW)/2, y: done[1] + (done[3]-s.DoneLabelH)/2, w: s.DoneLabelW, h: s.DoneLabelH, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.DoneLabelTex}) } diff --git a/internal/ui/screens.go b/internal/ui/screens.go index 4deabaa8..25e4e1b9 100644 --- a/internal/ui/screens.go +++ b/internal/ui/screens.go @@ -1,118 +1,172 @@ //go:build gl -// 界面屏幕:主菜单与暂停菜单(UI还原.md §2:布局/控件/版本角标复刻原版)。 +// 界面屏幕:主菜单与暂停菜单(UI还原.md §2:427×240 × guiScale)。 package ui -// Button 一个按钮:区域 + 悬停高亮 + 标签纹理。 +// Button 一个按钮:区域在 427×240 虚拟空间。 type Button struct { X, Y, W, H float32 - Tex uint32 // 标签纹理(预光栅化中文) + Tex uint32 LabelW float32 LabelH float32 Action string } -// TitleScreen 主菜单(UI还原.md §2:土质背景 + 标题 + 原版按钮 + 版本/版权角标)。 +// TitleScreen 主菜单(UI还原.md §2:3D 全景背景 + 缩放控件)。 type TitleScreen struct { TitleTex uint32 TitleW float32 TitleH float32 - SplashTex uint32 // 黄色 splash 文本(原版风格) + SplashTex uint32 SplashW float32 SplashH float32 - BgTex uint32 // 土质背景(16px dirt 平铺预渲染) - BtnTex uint32 // 原版按钮控件(widget/button.png 200×20) - BtnHover uint32 // 悬停高亮(button_highlighted.png) + BgTex uint32 + BtnTex uint32 + BtnHover uint32 Buttons []Button - Version string // 左下角版本(原版角标) - Copyright string // 右下角版权 - VersionTex uint32 // 版本/版权 CJK 光栅纹理(中文字形) + Version string + Copyright string + VersionTex uint32 VersionW float32 VersionH float32 CopyrightTex uint32 CopyrightW float32 CopyrightH float32 + + scale, ox, oy float32 + worldBg bool } -// HitTest 命中检测(按钮区域)。 +// Layout 按窗口与整数 guiScale 放置 427×240 虚拟画布(UI还原.md §3.1)。 +func (s *TitleScreen) Layout(winW, winH, scale float32) { + if scale < 1 { + scale = 1 + } + s.scale = scale + vw, vh := 427*scale, 240*scale + s.ox = (winW - vw) / 2 + s.oy = (winH - vh) / 2 +} + +func (s *TitleScreen) mapped(vx, vy, vw, vh float32) (x, y, w, h float32) { + sc := s.scale + if sc < 1 { + sc = 1 + } + return s.ox + vx*sc, s.oy + vy*sc, vw * sc, vh * sc +} + +// SetWorldBg 已绘制 3D 世界时只加遮罩,不再铺泥土墙。 +func (s *TitleScreen) SetWorldBg(ok bool) { s.worldBg = ok } + +// HitTest 命中检测(屏幕像素)。 func (s *TitleScreen) HitTest(mx, my float32) string { for _, b := range s.Buttons { - if mx >= b.X && mx <= b.X+b.W && my >= b.Y && my <= b.Y+b.H { + x, y, w, h := s.mapped(b.X, b.Y, b.W, b.H) + if mx >= x && mx <= x+w && my >= y && my <= y+h { return b.Action } } return "" } -// Draw 绘制主菜单(原版布局:背景 → 标题+splash → 按钮列 → 版本/版权角标)。 +// Draw 绘制主菜单(遮罩 → 标题+splash → 按钮 → 角标)。 func (s *TitleScreen) Draw(r *Renderer, mx, my float32) { w, h := float32(r.w), float32(r.h) - // 土质背景(UI还原.md §2 背景) - if s.BgTex != 0 { + if s.worldBg { + r.DrawRect(0, 0, w, h, [4]float32{0, 0, 0, 0.38}) + } else if s.BgTex != 0 { r.pushQuad(quad{x: 0, y: 0, w: w, h: h, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.BgTex}) + r.DrawRect(0, 0, w, h, [4]float32{0, 0, 0, 0.25}) } else { r.DrawRect(0, 0, w, h, [4]float32{0.08, 0.1, 0.12, 1}) } - // 标题(居中偏上,原版 logo 位置) - if s.TitleTex != 0 { - r.pushQuad(quad{x: w/2 - s.TitleW/2, y: h * 0.15, w: s.TitleW, h: s.TitleH, + sc := s.scale + if sc < 1 { + sc = 1 + } + if s.TitleTex != 0 && s.TitleH > 0 { + th := float32(42) * sc + tw := s.TitleW / s.TitleH * th + r.pushQuad(quad{x: w/2 - tw/2, y: s.oy + 16*sc, w: tw, h: th, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.TitleTex}) + if s.SplashTex != 0 { + sh := float32(12) * sc + sw := s.SplashW / s.SplashH * sh + r.pushQuad(quad{x: w/2 + tw/2 - sw, y: s.oy + 16*sc + th + 2*sc, w: sw, h: sh, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.SplashTex}) + } } - // splash(标题右下,原版黄色倾斜文本——MVP 水平放置) - if s.SplashTex != 0 { - r.pushQuad(quad{x: w/2 + s.TitleW/2 - s.SplashW + 20, y: h*0.15 + s.TitleH, w: s.SplashW, h: s.SplashH, - u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.SplashTex}) - } - // 按钮列(原版:居中偏下,200×20 控件 + 标签) for _, b := range s.Buttons { - hover := mx >= b.X && mx <= b.X+b.W && my >= b.Y && my <= b.Y+b.H + x, y, bw, bh := s.mapped(b.X, b.Y, b.W, b.H) + hover := mx >= x && mx <= x+bw && my >= y && my <= y+bh tex := s.BtnTex if hover && s.BtnHover != 0 { tex = s.BtnHover } if tex != 0 { - r.pushQuad(quad{x: b.X, y: b.Y, w: b.W, h: b.H, u0: 0, v0: 0, u1: 1, v1: 1, + r.pushQuad(quad{x: x, y: y, w: bw, h: bh, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: tex}) } if b.Tex != 0 { - // 标签居中 - r.pushQuad(quad{x: b.X + (b.W-b.LabelW)/2, y: b.Y + (b.H-b.LabelH)/2, w: b.LabelW, h: b.LabelH, + lw, lh := b.LabelW*sc*0.55, b.LabelH*sc*0.55 + r.pushQuad(quad{x: x + (bw-lw)/2, y: y + (bh-lh)/2, w: lw, h: lh, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: b.Tex}) } } - // 版本角标(左下)与版权(右下,原版样式;中文用预光栅纹理) if s.VersionTex != 0 { - r.pushQuad(quad{x: 4, y: h - s.VersionH - 4, w: s.VersionW, h: s.VersionH, + r.pushQuad(quad{x: 8, y: h - s.VersionH - 8, w: s.VersionW, h: s.VersionH, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.VersionTex}) } else if s.Version != "" { r.DrawText(8, h-12, s.Version, 1, [4]float32{1, 1, 1, 1}) } if s.CopyrightTex != 0 { - r.pushQuad(quad{x: w - s.CopyrightW - 4, y: h - s.CopyrightH - 4, w: s.CopyrightW, h: s.CopyrightH, + r.pushQuad(quad{x: w - s.CopyrightW - 8, y: h - s.CopyrightH - 8, w: s.CopyrightW, h: s.CopyrightH, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.CopyrightTex}) } else if s.Copyright != "" { r.DrawText(w-r.TextWidth(s.Copyright, 1)-8, h-12, s.Copyright, 1, [4]float32{1, 1, 1, 1}) } } -// PauseScreen 暂停菜单(UI还原.md §2:半透明遮罩 + 原版按钮)。 +// PauseScreen 暂停菜单(UI还原.md §2:半透明遮罩 + 缩放按钮)。 type PauseScreen struct { - TitleTex uint32 - TitleW float32 - TitleH float32 - BtnTex uint32 - BtnHover uint32 - Buttons []Button + TitleTex uint32 + TitleW float32 + TitleH float32 + BtnTex uint32 + BtnHover uint32 + Buttons []Button + scale, ox, oy float32 +} + +// Layout 与主菜单同一套 427×240 缩放。 +func (s *PauseScreen) Layout(winW, winH, scale float32) { + if scale < 1 { + scale = 1 + } + s.scale = scale + vw, vh := 427*scale, 240*scale + s.ox = (winW - vw) / 2 + s.oy = (winH - vh) / 2 +} + +func (s *PauseScreen) mapped(vx, vy, vw, vh float32) (x, y, w, h float32) { + sc := s.scale + if sc < 1 { + sc = 1 + } + return s.ox + vx*sc, s.oy + vy*sc, vw * sc, vh * sc } // HitTest 命中检测。 func (s *PauseScreen) HitTest(mx, my float32) string { for _, b := range s.Buttons { - if mx >= b.X && mx <= b.X+b.W && my >= b.Y && my <= b.Y+b.H { + x, y, w, h := s.mapped(b.X, b.Y, b.W, b.H) + if mx >= x && mx <= x+w && my >= y && my <= y+h { return b.Action } } @@ -123,23 +177,59 @@ func (s *PauseScreen) HitTest(mx, my float32) string { func (s *PauseScreen) Draw(r *Renderer, mx, my float32) { w, h := float32(r.w), float32(r.h) r.DrawRect(0, 0, w, h, [4]float32{0, 0, 0, 0.6}) - if s.TitleTex != 0 { - r.pushQuad(quad{x: w/2 - s.TitleW/2, y: h * 0.15, w: s.TitleW, h: s.TitleH, + sc := s.scale + if sc < 1 { + sc = 1 + } + if s.TitleTex != 0 && s.TitleH > 0 { + th := float32(18) * sc + tw := s.TitleW / s.TitleH * th + r.pushQuad(quad{x: w/2 - tw/2, y: s.oy + 28*sc, w: tw, h: th, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.TitleTex}) } for _, b := range s.Buttons { - hover := mx >= b.X && mx <= b.X+b.W && my >= b.Y && my <= b.Y+b.H + x, y, bw, bh := s.mapped(b.X, b.Y, b.W, b.H) + hover := mx >= x && mx <= x+bw && my >= y && my <= y+bh tex := s.BtnTex if hover && s.BtnHover != 0 { tex = s.BtnHover } if tex != 0 { - r.pushQuad(quad{x: b.X, y: b.Y, w: b.W, h: b.H, u0: 0, v0: 0, u1: 1, v1: 1, + r.pushQuad(quad{x: x, y: y, w: bw, h: bh, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: tex}) } if b.Tex != 0 { - r.pushQuad(quad{x: b.X + (b.W-b.LabelW)/2, y: b.Y + (b.H-b.LabelH)/2, w: b.LabelW, h: b.LabelH, + lw, lh := b.LabelW*sc*0.55, b.LabelH*sc*0.55 + r.pushQuad(quad{x: x + (bw-lw)/2, y: y + (bh-lh)/2, w: lw, h: lh, u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: b.Tex}) } } } + +// DeathScreen 死亡界面(UI还原.md §2:原因 + 重生 / 回标题;screen=11)。 +type DeathScreen struct { + PauseScreen + Cause string + CauseTex uint32 + CauseW float32 + CauseH float32 +} + +func (s *DeathScreen) Draw(r *Renderer, mx, my float32) { + s.PauseScreen.Draw(r, mx, my) + sc := s.scale + if sc < 1 { + sc = 1 + } + w := float32(r.w) + if s.CauseTex != 0 && s.CauseH > 0 { + th := float32(14) * sc + tw := s.CauseW / s.CauseH * th + r.pushQuad(quad{x: w/2 - tw/2, y: s.oy + 56*sc, w: tw, h: th, + u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.CauseTex}) + return + } + if s.Cause != "" { + r.DrawText(w/2-r.TextWidth(s.Cause, 1)/2, s.oy+56*sc, s.Cause, 1, [4]float32{1, 0.7, 0.7, 1}) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 73a87ef7..13d6348d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -73,6 +73,21 @@ func New(winW, winH int, scale float32) (*Renderer, error) { return r, nil } +// Resize 更新窗口像素尺寸与整数 guiScale(渲染.md §5.4、UI还原.md §3.1)。 +func (r *Renderer) Resize(winW, winH int, scale float32) { + if winW < 1 { + winW = 1 + } + if winH < 1 { + winH = 1 + } + r.w, r.h = int32(winW), int32(winH) + if scale < 1 { + scale = 1 + } + r.scale = scale +} + // SetFont 设置位图字体(UI还原.md §4:上传字形图集纹理)。 func (r *Renderer) SetFont(at *font.Atlas) { r.fontAtlas = at @@ -91,6 +106,15 @@ func (r *Renderer) SetFont(at *font.Atlas) { gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) } +// DrawImage 按像素尺寸画一张已上传纹理。 +func (r *Renderer) DrawImage(tex uint32, x, y, w, h float32) { + if r == nil || tex == 0 || w <= 0 || h <= 0 { + return + } + r.pushQuad(quad{x: x, y: y, w: w, h: h, u0: 0, v0: 0, u1: 1, v1: 1, + r: 1, g: 1, b: 1, a: 1, tex: tex}) +} + // DrawRect 绘制纯色矩形。 func (r *Renderer) DrawRect(x, y, w, h float32, rgba [4]float32) { r.pushQuad(quad{x: x, y: y, w: w, h: h, u0: 0, v0: 0, u1: 1, v1: 1, @@ -161,6 +185,14 @@ func (r *Renderer) UploadImage(img image.Image) uint32 { return tex } +// DeleteTexture 释放 UploadImage 产生的纹理(Loading 刷新日志行时复用)。 +func (r *Renderer) DeleteTexture(id uint32) { + if r == nil || id == 0 { + return + } + gl.DeleteTextures(1, &id) +} + // Begin 开始一帧 UI 绘制(清空批次)。 func (r *Renderer) Begin() { r.quads = r.quads[:0] } diff --git a/internal/world/world.go b/internal/world/world.go index c0df9218..37304177 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -28,6 +28,7 @@ const ( stateQueued // 已入队等待生成 stateGenerating // worker 生成中 stateActive // 已挂回世界,可读可渲染 + stateUnloading // 视距外,待保存后释放 ) // World 世界实例。 @@ -48,6 +49,8 @@ type World struct { results chan *chunk.Chunk done chan struct{} // 关闭信号(goroutine 可退出) + unloaded []*chunk.Chunk // Update 卸下的区块,调用方 DrainUnloaded + wg sync.WaitGroup } @@ -155,9 +158,42 @@ drained: // worker 繁忙:保持 queued,下帧重建队列时重试 } } + w.evictFarLocked(pcx, pcz, vd) w.mu.Unlock() } +func (w *World) evictFarLocked(pcx, pcz, vd int32) { + margin := vd + 2 + for k, c := range w.chunks { + if abs32(c.CX-pcx) <= margin && abs32(c.CZ-pcz) <= margin { + continue + } + if w.states[k] == stateGenerating { + continue + } + w.states[k] = stateUnloading + w.unloaded = append(w.unloaded, c) + delete(w.chunks, k) + delete(w.states, k) + } +} + +func abs32(v int32) int32 { + if v < 0 { + return -v + } + return v +} + +// DrainUnloaded 取出视距外已卸载区块(存档 / RemoveChunk)。 +func (w *World) DrainUnloaded() []*chunk.Chunk { + w.mu.Lock() + defer w.mu.Unlock() + out := w.unloaded + w.unloaded = nil + return out +} + // dist 区块中心到玩家的欧氏距离(区块单位)。 func (w *World) dist(c chunkCoord) float64 { dx := float64(c.cx) - w.playerX/16 diff --git a/internal/world/world_test.go b/internal/world/world_test.go index f804c3c9..ce2c8054 100644 --- a/internal/world/world_test.go +++ b/internal/world/world_test.go @@ -99,3 +99,22 @@ func TestTorchLightCycle(t *testing.T) { t.Fatalf("移除火把后侧邻光期望 0,实际 %d", got) } } + +func TestEvictFar(t *testing.T) { + w := newTestWorld(t, 2) + waitChunk(t, w, 0, 0) + n := w.Count() + if n == 0 { + t.Fatal("应已加载出生区块") + } + for i := 0; i < 40; i++ { + w.Update(800, 64, 800, 8*time.Millisecond) + } + gone := w.DrainUnloaded() + if len(gone) == 0 { + t.Fatal("远离后应卸载旧区块") + } + if w.ChunkLoaded(8, 8) { + t.Fatal("原点区块应已卸载") + } +} diff --git a/resourcepacks/README.md b/resourcepacks/README.md deleted file mode 100644 index eacd9098..00000000 --- a/resourcepacks/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# resourcepacks/ — 素材包目录 - -> 素材包(纹理/模型/语言/音效覆盖)放这里;也接受放到 [`../mods/`](../mods/README.md)(主投放点)。 -> 格式见 [`../文档/设计.md`](../文档/设计.md) §1.3(内容包统一格式)。 - -## 已就位的素材包 - -- **`texture-pack-default1.20.5-26.2/`** — 原生 Minecraft 1.20.5 资源包(pack.mcmeta,pack_format 34/46), - 含 atlases / blockstates(1198)/ models(3928)/ textures / font / lang(en_us) 完整结构。 - 客户端/服务器启动时自动加载,作为默认材质来源(初版材质基线)。 - -## 支持两种格式(自动识别) - -1. **原生格式**(推荐,用户下载的包直接放):根目录 `pack.mcmeta` + `assets//...`,自动视为素材包,无需转换。 -2. **扩展格式**(可选):`manifest.json`(`"type": "resource"`,可声明 `priority`),包结构同 Mod 格式。 - -## 规则 - -- 优先级:`功能 Mod 资源 > 素材包 > 光影包(仅渲染阶段) > 默认嵌入素材`。 -- 同类型冲突:`priority` 大者优先,相同则后加载者覆盖并告警。 -- 开发模式支持热重载(改图 → 图集重建 → mesh 重传,见 场景/热更新.md)。 diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 917b7453..b7754286 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -29,6 +29,32 @@ if ($zig) { $env:CC = $cc $env:CGO_ENABLED = "1" +# 双击 bat 时 Explorer 的 PATH 可能没有 go;按常见位置补上 +if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + $goExe = $null + $exact = @() + if ($env:GOROOT) { $exact += (Join-Path $env:GOROOT "bin\go.exe") } + $exact += "C:\Program Files\Go\bin\go.exe" + foreach ($p in $exact) { + if (Test-Path -LiteralPath $p) { $goExe = $p; break } + } + if (-not $goExe) { + $globs = @( + (Join-Path $env:USERPROFILE "sdk\go*\bin\go.exe"), + "D:\*\sdk\go*\bin\go.exe" + ) + $hit = Get-ChildItem -Path $globs -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($hit) { $goExe = $hit.FullName } + } + if ($goExe) { + $env:Path = "$(Split-Path $goExe);$env:Path" + Write-Host "== 使用 go: $goExe ==" + } else { + Write-Host "== 未找到 go:请安装 Go 或把 go.exe 加入 PATH。" + exit 1 + } +} + $tags = "gl" if (-not $Release) { $tags = "$tags dev" } @@ -40,7 +66,7 @@ try { & (Join-Path $PSScriptRoot "make_icon.ps1") if ($LASTEXITCODE -ne 0) { throw "图标生成失败(make_icon.ps1)" } } - go build -tags $tags -trimpath -ldflags "-s -w" -o bin/mc-client.exe ./cmd/client + go build -tags $tags -trimpath -ldflags "-s -w -H windowsgui" -o bin/mc-client.exe ./cmd/client go build -trimpath -ldflags "-s -w" -o bin/mc-server.exe ./cmd/server # 资源入 bin/(可执行文件目录解析:双击/快捷方式运行无需依赖工作目录) diff --git a/scripts/make_nsis.ps1 b/scripts/make_nsis.ps1 index 7aa4c411..07580622 100644 --- a/scripts/make_nsis.ps1 +++ b/scripts/make_nsis.ps1 @@ -1,5 +1,6 @@ # 年糕历险记 —— NSIS 正式安装器构建脚本(构建与发布.md §8 交付方案) -# 用法:pwsh -File scripts/make_nsis.ps1 [-Version 0.1.0] +# 用法:根目录 打包-安装包.bat [版本] +# 或 pwsh -File scripts/make_nsis.ps1 [-Version 0.1.0] # 前置:C 工具链(tools/ 下 zig 或 MinGW,构建 GL 客户端需要); # NSIS 3(自动查找 tools/nsis/nsis-*/makensis.exe,其次 PATH 中的 makensis) # 产物:dist/年糕历险记-Setup-v<版本>.exe(简体中文向导、快捷方式选项、完成页运行选项) @@ -8,9 +9,9 @@ param([string]$Version = "0.1.0") $ErrorActionPreference = "Stop" $root = Split-Path $PSScriptRoot -Parent -# 1) 构建 bin/(复用 build.ps1:定位 zig/gcc,产出 mc-client.exe / mc-server.exe 及资源) -& (Join-Path $PSScriptRoot "build.ps1") -if ($LASTEXITCODE -ne 0) { throw "构建失败(build.ps1)" } +# 1) 构建 bin/(Release:tags gl,无 dev;构建与发布.md §3 / §8) +& (Join-Path $PSScriptRoot "build.ps1") -Release +if ($LASTEXITCODE -ne 0) { throw "构建失败(build.ps1 -Release)" } # 2) 定位 makensis:优先 tools/nsis/(本项目自带 NSIS 3.10),其次系统 PATH $makensisPath = (Get-ChildItem (Join-Path $root "tools\nsis") -Recurse -Filter makensis.exe -ErrorAction SilentlyContinue | Select-Object -First 1).FullName diff --git a/文档/场景/UI与输入.md b/文档/场景/UI与输入.md index 0a1b3df0..23f186a8 100644 --- a/文档/场景/UI与输入.md +++ b/文档/场景/UI与输入.md @@ -21,23 +21,33 @@ | 界面 | 内容 | | --- | --- | | HUD | 热键栏、生命/饥饿、准星、进度条(破坏进度,见 方块交互与动画.md §4) | -| 物品栏 | 36 格 + 9 热键栏(模型见 物品与背包.md §3) | -| 合成台 | 2×2/3×3 网格 + 输出格 | -| 熔炉 | 燃料 + 输入 + 输出 + 进度 | -| 容器 | 箱子 27 格、双箱 54 格 | +| 物品栏 | 27 主区 + 9 热键栏 + 盔甲 4 + 副手 + 2×2 合成(物品与背包.md §3、UI还原.md §3.3) | +| 合成台 | 3×3 网格 + 输出格 + 27 主背包 + 9 热键栏 + 最小配方书 | +| 熔炉 | 燃料 + 输入 + 输出 + 进度 + 玩家背包 | +| 容器 | 箱子 27 格 + 玩家 27+9(双箱后置) | +| 创造栏 | 标签 / 搜索 / 删除槽(创造模式 E) | +| 选择世界 | 单人游戏 → 存档列表;进入 / 新建 / 删除(UI还原.md §3.6) | +| 死亡 | `screen=11`:原因文本 + 重生 / 回标题(UI还原.md §2、实体系统.md §6) | +| 启动 Loading | `screen=13`:泥土背景 + 左下角日志环;出窗后、主菜单前(UI还原.md §2) | +| 暂停 | `screen=2`:继续 / 设置 / 保存存档 / 保存并退出 / 退出游戏(不保存,回标题;存档与持久化.md §2) | ## 4. 拖拽与 Shift 点击(状态机) -- 鼠标槽位交互状态机: +- 鼠标槽位交互: ```text -空 → 拿起(左键) → 悬停/放下 → 空 - → 拖拽(左键按住移动) → 等分堆叠(9 份均分) - → Shift+点击 = 快捷移动(物品栏 ↔ 容器/合成格) +左键:空手拿起整栈;手持放下 / 同物品合并 / 不同物品交换 +右键:空手拿一半(向上取整);手持时向空槽或同物品放 1 个 +Shift+左键:物品栏 ↔ 热键栏;盔甲/副手 → 背包;工作台网格 ↔ 背包 + 输出格 = 批量合成直到材料不足或背包满 +按住左键拖过槽:手持栈等分放入空/同物品槽 +按住右键拖过槽:每格放 1 个 +双击(同槽 250ms):同类从当前存储区聚到光标直到堆叠上限 ``` - 每次操作必须**服务器权威校验**(多人,见 多人同步.md §5),客户端只做即时反馈 + 回滚。 -- 规则表(单一来源):`canPlace(stack, slot)`、`stackLimit`、`mergeRule`,客户端与服务器共用同一实现(防双端不一致)。 +- 规则表(单一来源):`CanPlace`、`ClickLeft` / `ClickRight`、`SpreadEven` / `SpreadOne`、`Gather`、`ShiftMove`、`Merge`。 +- 输出格、盔甲/副手不参与均分(仍走 `CanPlace`)。 ## 5. 多语言 @@ -52,8 +62,9 @@ | 键位映射 | `config/client.yaml` 可配置;动作表(前进/跳/挖/用/滚轮选块) | | 鼠标捕获 | 游戏内捕获并隐藏;打开 UI/菜单释放 | | 焦点 | 窗口失焦自动暂停游戏与音频(设计.md §11.2) | -| 滚轮 | 热键栏切换;数字键 1–9 直选 | -| 长按 | 破坏按住累积(方块交互与动画.md §3);放置长按连续放置(带间隔) | +| 滚轮 | 热键栏切换,对齐原版 Java:**上滚 = 槽位 −1(左)**,下滚 = +1(右);数字键 1–9 直选 | +| 长按 | 破坏按住累积,挖完一块后继续对准星方块(方块交互与动画.md §3);放置长按连续放置(带间隔);挥击音仅按下边沿 | +| 窗口 | 可最大化;**F11** / **Alt+Enter** 切换全屏(渲染.md §5.4);framebuffer 变化后更新 viewport / 投影 / 整数 `guiScale` | - 输入事件只发到**当前模式**(游戏 / 菜单 / 聊天输入),避免聊天打字触发移动。 @@ -78,3 +89,11 @@ | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:UI 渲染架构、界面清单、拖拽状态机、多语言、输入系统、踩坑清单 | — | +| 2026-08-17 | §6:滚轮上=槽位−1(原版方向);F11/Alt+Enter 全屏;窗口可最大化 | — | +| 2026-08-18 | §6:左键挥空 nodamage 仅按下边沿 | — | +| 2026-08-18 | §3:选择世界 / 创建世界 | — | +| 2026-08-18 | §3/§4:物品栏 2×2+盔甲/副手;左/右/Shift 槽位规则落地 | — | +| 2026-08-18 | §3/§4:拖拽均分、双击聚堆、熔炉/箱子/创造栏 | — | +| 2026-08-19 | §变更记录去掉「均分/双击后置」(已于同日后一条落地) | — | +| 2026-08-19 | §3:死亡界面 `screen=11` | — | +| 2026-08-19 | §3:Loading `screen=13`;暂停保存/不保存分流 | — | diff --git a/文档/场景/UI还原.md b/文档/场景/UI还原.md index 93adaf7f..a3a14ff2 100644 --- a/文档/场景/UI还原.md +++ b/文档/场景/UI还原.md @@ -18,17 +18,20 @@ | 界面 | 还原要点 | | --- | --- | -| 主菜单 | 标题 logo(**中文「年糕历险记」**,布局/风格保持原版)、按钮(单人/多人/设置/退出)、背景全景图、版本号角标、语言选择 | -| 暂停菜单 | 继续 / 保存并退出 / 设置 / 对局信息(单人游戏暂停,多人不暂停) | +| 主菜单 | 标题 logo(**中文「年糕历险记」**,布局/风格保持原版)、按钮(单人/多人/设置/退出)、**3D 世界全景背景**(慢旋镜头 + 半透明遮罩)、版本号角标、语言选择。控件在 427×240 虚拟空间布局,按整数 `guiScale` 放大;窗口最大化/全屏必须跟着变大。**单人游戏进入选择世界,不直接开档。** | +| 选择世界 | 列出 `worlds/*/level.dat`;点选一行;**进入选中的世界** / **创建新的世界** / **删除**(确认后 `RemoveAll`)/ 取消。Esc 回主菜单。 | +| 创建世界 | 世界名称、种子(空则随机)、**游戏模式(生存/创造)**;创建后写入 `level.dat` 并进入。 | +| 启动 Loading | `screen=13`:进程启动后立刻出窗。泥土平铺背景(`block/dirt`,与菜单土墙同一套)、标题「正在加载…」、**左下角**最近约 8 行状态/日志(内容包、图集、出生区块等)。无强制进度条。结束后进主菜单(`screen=0`)。 | +| 暂停菜单 | 继续 / 设置 / **保存存档**(写盘后留在暂停) / **保存并退出**(写盘 + `SavePool.Wait` + 回标题,不关进程) / **退出游戏**(不写盘,按磁盘重载当前档作菜单背景后回标题)。对局信息后置。单人打开暂停世界不 Tick,多人不暂停。 | | HUD | 热键栏(9 格)、生命(10 心)/ 饥饿(10 鸡腿)、经验条、等级数字、准星、呼吸气泡、聊天框、准星指向的名称/血条悬浮 | | 物品栏 | 27+9 槽、盔甲 4 槽、副手槽、2×2 合成格 + 输出、玩家模型展示 | -| 合成台 | 3×3 网格 + 输出格 + 配方书入口 | -| 熔炉 | 燃料 / 输入 / 输出三槽 + 火焰进度 + 箭头进度 | -| 箱子 | 27 格 + 9 热键栏(双箱 54 格) | -| 创造物品栏 | 标签页、搜索框、物品预览、删除槽 | +| 合成台 | 3×3 网格 + 输出格 + **27 主背包 + 9 热键栏** + 配方书(搜索/滚动/解锁) | +| 熔炉 | 燃料 / 输入 / 输出三槽 + 火焰进度 + 箭头进度 + 27 主背包 + 热键栏 | +| 箱子 | 27 格 + 玩家 27+9(双箱 54 格后置) | +| 创造物品栏 | 标签页(全部/方块/物品)、搜索、删除槽、底部热键栏 | | 设置界面 | 视频 / 音频 / 控制(键位)/ 语言 | -| 死亡界面 | 原因文本、重生 / 返回标题按钮 | -| 配方书 | 已解锁配方、搜索、点击自动填充(工作台与合成表.md §6) | +| 死亡界面 | `screen=11`:原因文本、重生 / 返回标题按钮(对齐暂停按钮样式) | +| 配方书 | 工作台右侧列表:滚动、搜索、已解锁可填格(工作台与合成表.md §6) | ## 3. 像素级布局规格 @@ -62,13 +65,28 @@ | 界面 | 要点 | | --- | --- | -| 合成台 | 3×3 网格 (30, 17) 起,输出 (124, 35) | -| 熔炉 | 输入 (56, 17)、燃料 (56, 53)、输出 (116, 35);火焰与箭头进度条按其纹理坐标 | +| 合成台 | 3×3 网格 (30, 17) 起,输出 (124, 35);主物品区 (7, 83)、热键栏 (7, 141) 与物品栏同规格;配方书按钮在窗口右侧 | +| 熔炉 | 输入 (56, 17)、燃料 (56, 53)、输出 (116, 35);主区 (7, 83)、热键栏 (7, 141);火焰/箭头按 Burn/Cook 比例 | +| 箱子 | 27 箱格 (8, 18) 起 3×9;玩家主区 (8, 84)、热键栏 (8, 142) | +| 创造栏 | 195×136 窗口;标签行顶部;搜索框;物品网格 9×5;删除槽右下;热键栏贴底 | | HUD 热键栏 | 182 × 22,屏幕底部居中,9 槽 | -| 生命 / 饥饿 | 每图标 9×9,屏幕左下角;受伤红闪 | +| 生命 / 饥饿 | 每图标 9×9;生命在热键栏左上、饥饿在右上;受伤红闪 | +| 呼吸气泡 | 眼睛在水时画在饥饿行上方;10 格,满气 300 tick;`gui/sprites/hud/air` / `air_bursting`;格数 = ceil(airTicks/30) | | 经验条 | 182 × 5,热键栏正上方 | | 准星 | 屏幕正中 15×15(十字) | -| 准星方块名 | 屏幕左下角(生命行上方),本地化名称 + 1px 阴影 | +| 准星识别条 | 屏幕左下角(生命行上方):16×16 方块预览 + 本地化名,半透明暗底(约 rgba 0,0,0,0.62),内边距 3px | +| 附近音效字幕 | 识别条正下方,最多 4 行,约 3s 淡出;无准星方块时仍画在同一锚点(音频.md §6.3) | + +### 3.6 选择世界 / 创建世界 + +| 区域 | 位置(427×240 虚拟空间) | +| --- | --- | +| 标题 | 水平居中,y=12 | +| 世界列表 | (63, 36) 起,宽 300;每行高 24,最多 6 行 | +| 进入 / 创建 | y=186,宽 148 / 148,间距 4 | +| 删除 / 取消 | y=210,宽 148 / 148 | +| 创建页名称/种子框 | 居中宽 200,高 18 | +| 创建页模式切换 | 名称/种子下方:生存 ↔ 创造(写入 `level.dat` 游戏模式) | ### 3.5 小地图(左上角,项目定制) @@ -77,7 +95,7 @@ - 中心白点 = 玩家位置;地图每 1.5 秒(30 tick)重建一次,GPU 上传走渲染线程(tick 线程只做 CPU 构建)。 - 原版无小地图(为社区模组功能),本项目作定制界面提供,布局不与原版对比。 -> 上表数值以原版 Java 版**实测为准**:实现时逐界面截图校准 ±1px,登记到「UI 布局基线表」(`docs/ui-layout-baseline.md`,随代码库维护)。 +> 上表数值以原版 Java 版**实测为准**:实现时逐界面截图校准 ±1px。独立基线文件 `docs/ui-layout-baseline.md` **仓库中不存在**,以本篇表格 + 截图验收为准,勿另开空文件。 ## 4. 字体与文本 @@ -91,10 +109,11 @@ | 操作 | 行为 | | --- | --- | -| 左键 | 拿起 / 放置(半组规则) | -| 右键 | 均分堆叠(按住拖拽 = 9 份均分) | -| Shift+点击 | 快捷移动(物品栏 ↔ 容器/合成格) | -| 双击 | 拿起同类全部 | +| 左键 | 拿起整栈 / 放下 / 同物品合并 | +| 右键 | 空手拿一半(向上取整);手持时放 1 个;按住拖过槽每格放 1 | +| Shift+左键 | 快捷移动:物品栏 ↔ 热键栏;工作台网格 ↔ 背包;**输出格 = 批量合成**直到材料不足或背包满 | +| 按住左键拖 | 手持栈等分放入经过的空/同物品槽 | +| 双击 | 同槽 250ms 内第二次左键:同类聚到光标直到堆叠上限 | | 滚轮 / 数字键 1–9 | 切换热键栏槽 | | Q | 丢弃(可配置关闭) | | Esc | 关闭界面 / 返回上一级 / 暂停菜单 | @@ -104,13 +123,14 @@ ## 6. 动画与特效还原 -- 物品栏窗口**滑入 / 滑出**(缓动,约 150ms)。 +- 物品栏/容器窗口打开:从约 0.85× 缩放到 1.0×(约 150ms);关闭反向。 - 菜单 / 暂停界面淡入淡出。 - 物品悬停 tooltip:显示物品名 + 描述/属性(延迟与样式按原版)。 -- 热键栏选中槽高亮框;准星指向方块显示名称。 +- 热键栏选中槽高亮框;切槽时图标 1.4× → 1.0× punch(约 150ms)。 +- 拾取飞入:掉落物进包后图标从世界投影飞向目标热键栏槽并缩小。 - 工具耐久条:物品栏图标下方绿色细条(耐久比例)。 - 破坏进度条:HUD 中央裂纹 + 进度环(方块交互与动画.md §4)。 -- 不还原的(明确豁免):原版无的物品移动平滑动画,不做。 +- 不还原的(明确豁免):拖动物品本身不做平滑插值。 ## 7. 纹理来源与素材包联动 @@ -150,3 +170,12 @@ | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:界面清单、像素级布局规格、字体、交互/动画还原、素材包联动、截图验收 | — | +| 2026-08-17 | §3.4:水下呼吸气泡布局(饥饿行上方,10 格,ceil(air/30)) | — | +| 2026-08-17 | §2:主菜单 427×240×guiScale 缩放;背景为 3D 世界全景而非固定泥土墙 | — | +| 2026-08-18 | §3.4:准星识别条改为图标+暗底;附近音效字幕叠在其下 | — | +| 2026-08-18 | §2/§3.6:单人→选择世界;新建/删除存档;暂停退出回标题 | — | +| 2026-08-18 | §3.3/§3.4/§5:物品栏 2×2+盔甲/副手;工作台补 27 主背包;左/右/Shift 规则落地;最小配方书 | — | +| 2026-08-18 | §3.4/§5/§6:熔炉/箱子/创造栏;拖拽均分与双击;热键栏 punch、开窗缩放、拾取飞入 | — | +| 2026-08-19 | §2/§3.6:创建世界补游戏模式切换;§3 去掉不存在的 `docs/ui-layout-baseline.md` 引用 | — | +| 2026-08-19 | §2:死亡界面 `screen=11`(原因 + 重生 / 回标题) | — | +| 2026-08-19 | §2:启动 Loading `screen=13`;暂停拆成保存存档 / 保存并退出 / 退出游戏(不保存) | — | diff --git a/文档/场景/世界生态.md b/文档/场景/世界生态.md index 7199c577..14d77ecf 100644 --- a/文档/场景/世界生态.md +++ b/文档/场景/世界生态.md @@ -63,11 +63,9 @@ func placeTree(w WorldView, x, y, z int32, height int) { - 随机刻:**每个 section 每 tick 只随机选少量方块**(如 3 个),**禁止全扫**。 - 演化规则(均为 tick 处理): - - 作物生长:累积生长值,达阈值升级 stage; - - 草蔓延:邻近随机方块为 dirt 且光照达标 → 变 grass_block; - - 树苗 → 树(走 §3.1 的落树逻辑); - - 冰融化、火传播、雪融化。 -- 每个规则必须是**幂等、确定性**(同状态同结果),否则存档回放不一致。 + - **本轮已做**:草蔓延(邻格 `grass_block`、自身 `dirt`、上方天空光 ≥9 → 变草)。 + - **后置**:作物生长、树苗→树、冰融化、火传播、雪融化(无耕地/树苗方块)。 +- 每个规则必须是**幂等、确定性**(同 seed + 坐标 + tick 同结果),否则存档回放不一致。 ## 5. 液体流动(水/岩浆) @@ -97,3 +95,4 @@ func placeTree(w WorldView, x, y, z int32, height int) { | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:双噪声群系、后处理 pass、树跨区块、随机刻、液体、踩坑清单 | — | +| 2026-08-19 | §4:本轮只做草蔓延;作物/树苗/火/冰后置。§5:水 meta 0 源 / 1–7 流,岩浆静态 | — | diff --git a/文档/场景/动物体系.md b/文档/场景/动物体系.md index d2c296a7..f26954a0 100644 --- a/文档/场景/动物体系.md +++ b/文档/场景/动物体系.md @@ -23,6 +23,8 @@ - 行为 AI 复用 实体系统.md §3 的状态机:`idle → wander → follow → flee → eat`。 - **跟随**:手持食物(小麦/胡萝卜)→ 动物跟随玩家(简单寻路,限频)。 +- **音效**(音频.md §6.2):闲置 `entity..ambient`(`ambientSoundTime`,`rand(1000) < time` 后重置为 −80);受击 `hurt`、致死 `death`;走路播脚下 `block..step`。 +- **物种**:`Animal.Species`(0 牛 / 1 猪 / 2 羊 / 3 鸡)。声音、外观、掉落、繁殖共用 `AnimalSpecies()`;缺字段时回退 `ID%4`。生成时随机写入;繁殖必须同种,幼崽继承父母。日后存档实体必须带 `species`(存档格式.md §4.4)。 ## 3. 繁殖系统 @@ -35,6 +37,7 @@ - 每区块同种动物上限(如 10 只);达到上限时喂食无效并提示; - 近亲繁殖限制(可选,后期:记录父母 ID)。 - 幼崽:体型缩小(渲染缩放)+ 不繁殖。 +- **会话接入**:`Manager.Tick` 对每只动物调用 `TickAnimal`(成长/冷却)。右键 `TryUseEntity`:小麦喂成年同种进入 love,附近另一只 love 则 `Breed`;剪刀剪羊;空桶挤牛奶。驯服/骑乘后置。 ## 4. 驯服与骑乘(后期) @@ -80,3 +83,6 @@ | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:动物行为、繁殖与数量控制、驯服骑乘、产物机制、生成规则、踩坑清单 | — | +| 2026-08-18 | §2:ambient / hurt / death 与动物脚步(方块 step) | — | +| 2026-08-18 | §2–§3:`Animal.Species`;繁殖必须同种 | — | +| 2026-08-19 | §3:会话接入 TickAnimal / 喂食繁殖 / 剪羊 / 牛奶 | — | diff --git a/文档/场景/区块管理.md b/文档/场景/区块管理.md index c3041ad8..e1e4efeb 100644 --- a/文档/场景/区块管理.md +++ b/文档/场景/区块管理.md @@ -111,7 +111,8 @@ type ChunkManager struct { ## 5. 卸载与保存钩子 -- `unloading` 时:`saveDirty` 为真才写盘(diff-only,见 [存档与持久化.md](存档与持久化.md))。 +- `unloading` 时:`saveDirty` 为真才写盘(本轮仍写**整 chunk** region 记录,不改 diff-only 格式)。 +- 视距 +2 格以外从 `chunks` 删除;调用方 `DrainUnloaded` 后 `Renderer.RemoveChunk`。 - 卸载前必须保证没有 worker 仍引用该 Chunk(状态机保证)。 ## 6. 并发约定 @@ -142,3 +143,4 @@ type ChunkManager struct { | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:布局、状态机、异步流水线、锁与并发、踩坑清单 | — | +| 2026-08-19 | §3/§5:视距外 unloading;SaveDirty 整 chunk 入 SavePool | — | diff --git a/文档/场景/存档与持久化.md b/文档/场景/存档与持久化.md index 3a762578..5e583dfb 100644 --- a/文档/场景/存档与持久化.md +++ b/文档/场景/存档与持久化.md @@ -10,14 +10,19 @@ - 只存 **diff(玩家改动)**,不存整块原始地形。 - 覆盖:保存时机、diff 化、异步保存池、原子写入、崩溃恢复、版本迁移。 -## 2. 保存时机(三处,缺一不可) +## 2. 保存时机(四处,缺一不可) | 时机 | 说明 | | --- | --- | | 定时 flush | 默认 30s,批量落盘(配置可调) | | Chunk 卸载 | `saveDirty` 为真才写(见 区块管理.md §5) | -| 关服/存档 | 全量 flush + `session.lock` 释放 | +| 暂停手动保存 | 「保存存档」:全量 flush + `SavePool.Wait()`,**不离开**暂停界面 | +| 关服/存档 | 全量 flush + `session.lock` 释放;**暂停「保存并退出」与窗口关闭必须立刻写盘**(含玩家位置/背包) | +- 「保存并退出」:写盘 + `Wait()` + 回标题(不关进程)。 +- 「退出游戏」(暂停):**不写盘**;按磁盘重载当前档作菜单 3D 背景,回标题。再进同一档不得带着未保存内存改动。 +- 窗口 X / 关进程:立刻写盘(若本局刚「弃档退出」则不再写,避免把已丢弃的脏数据落盘)。 +- 死亡「返回标题」仍写盘。 - **禁止**在每次方块变化时写盘(IO 风暴 + 主线程卡顿)。 - 变更只置 `saveDirty`;写盘全部走异步保存池。 @@ -34,8 +39,11 @@ ## 4. 异步保存池 - 独立 goroutine 消费保存队列;队列元素为**序列化后的字节**(不持有 Chunk 引用)。 -- 序列化在 worker 完成(不碰 GL、不碰主线程),写盘在保存 goroutine。 -- 失败重试 3 次,仍失败写错误日志并保留 `saveDirty`。 +- 序列化在调用方完成(不碰 GL),写盘在保存 goroutine。 +- 失败重试 3 次,仍失败写错误日志。 +- **本轮**:region 仍写整 chunk(现有 `EncodeChunk`),不改 diff-only 格式。关服 / 「保存存档」 / 「保存并退出」必须 `SavePool.Wait()`。 +- `Wait()` 只 drain 已入队任务,**不关闭**池;关进程才 `Close()`。 +- 30s 定时只入队,不在 tick 线程 `fsync`。 ```go type SavePool struct { @@ -47,8 +55,18 @@ type SavePool struct { ## 5. 原子写入与崩溃恢复 - 写**临时文件**(同目录 `.tmp`)→ `fsync` → 原子 `rename`(设计.md §9.2)。 -- 每条记录带长度 + CRC32([../存档格式.md](../存档格式.md) §5):损坏判定为丢弃该区块并告警,不崩溃。 +- 每条记录带长度 + CRC32([../存档格式.md](../存档格式.md) §5):损坏判定为丢弃该区块(`ReadChunk` 返回未保存),`LoadWorld` 继续载入其余区块,不整档失败。 +- **Windows 原子替换**:目标已存在时 `rename` 会失败,必须先把旧文件挪走再替换(`atomicReplace`)。 +- Region 写入:重写整个 region(8KB 头 + 打包扇区),禁止「新头 + 整份旧文件」拼接(会错位,载入报魔数错误)。 - `session.lock`:防双开;崩溃后残留锁文件 → 启动时提示强制解锁(记录日志)。 + +## 5.1 选档(客户端) + +- 存档目录:`worlds/<世界名>/`(设计.md §1.1)。 +- `ListWorlds`:扫描含 `level.dat` 的子目录,按 `level.dat` 修改时间新→旧。 +- `DeleteWorld`:校验世界名(禁止路径分隔与 `..`)后删除整个目录。 +- 新建:名称冲突时加 `-2`、`-3`;种子空则随机,非数字字符串按哈希。 +- 界面见 [UI还原.md](UI还原.md) §2、§3.6。 - 崩溃恢复路径:`临时文件存在 → 视为上次写入中断 → 校验后替换`。 ## 6. 版本迁移 @@ -78,3 +96,8 @@ type SavePool struct { | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:保存时机、diff-only、异步保存池、原子写入、迁移、踩坑清单 | — | +| 2026-08-17 | §2/§5:退出必存;Windows atomicReplace;region 整文件重写;坏块跳过 | — | +| 2026-08-17 | §5:ReadChunk 长度越界/CRC 失败不再冒泡为 LoadWorld 失败 | — | +| 2026-08-18 | §5.1:ListWorlds / DeleteWorld;选档 UI | — | +| 2026-08-19 | §4:SavePool 落地;本轮整 chunk 入队;关服 Wait;30s 不在 tick fsync | — | +| 2026-08-19 | §2/§4:暂停手动保存;退出不保存按磁盘重载;Wait 不关池 | — | diff --git a/文档/场景/实体系统.md b/文档/场景/实体系统.md index f9608d7a..fa85fc43 100644 --- a/文档/场景/实体系统.md +++ b/文档/场景/实体系统.md @@ -59,8 +59,12 @@ spawn → tick(激活区块内)→ damage → death → 掉落物 → despaw ## 6. 玩家实体状态 -- 生命 / 饥饿(饱和、消耗、恢复)/ 经验(可选)。 -- 死亡:掉落背包(可选保留)→ 重生点复活。 +- 生命 / 饥饿(饱和、消耗、恢复)/ 经验(可选,本轮不做)。 +- 饥饿规则(生存模式;创造不消耗): + - `Hunger` 0–20;`Saturation` ≥ 0(进食加上 `item.Def.Saturation`,上限不高于饥饿)。 + - `Exhaustion` 运行时累加(冲刺 / 跳跃 / 破坏),满 4.0 先扣饱和再扣饥饿;不落盘。 + - `Hunger == 0`:约每 4 秒扣 1 心;致死进入死亡状态,不立刻复活。 +- 死亡:`Dead` + `DeathCause`;世界与实体继续 Tick,玩家跳过移动/破坏/放置。背包掉落**可选保留**(本轮不做)。客户端 `screen=11`:原因 + 重生 / 回标题(UI还原.md §2)。重生恢复血/饿/气并送回 `level.dat.spawn`(缺则现坐标)。 - 玩家 = 实体 + 输入源 + 背包引用(见 物品与背包.md)。 ## 7. 踩坑清单 @@ -84,3 +88,4 @@ spawn → tick(激活区块内)→ damage → death → 掉落物 → despaw | --- | --- | --- | | (初始建立) | 初版:生命周期、AI 状态机与限频寻路、自然生成、掉落物、玩家状态、踩坑清单 | — | | (稳定性) | 卸载区块实体冻结(WorldView.ChunkLoaded):实体所在区块卸载后跳过 tick——否则失去碰撞支撑无限下坠到负坐标,光查询越界崩溃(chunk 光查询同步加越界防御);掉落物生成后 1 秒拾取延迟(掉落可见);实体受击白闪 HitFlash(打击反馈) | — | +| 2026-08-19 | §6:饥饿饱和/消耗/饿死;死亡状态机 + screen=11;背包掉落后置 | — | diff --git a/文档/场景/工作台与合成表.md b/文档/场景/工作台与合成表.md index 2c25ed97..5eb2417a 100644 --- a/文档/场景/工作台与合成表.md +++ b/文档/场景/工作台与合成表.md @@ -57,14 +57,16 @@ ## 5. 工作台界面与交互 -- 打开工作台 → 3×3 网格 + 输出格(UI与输入.md §3)。 -- 网格槽位操作复用背包规则表(物品与背包.md §3):放置/拿起/合并。 -- **Shift+点击输出格 = 批量合成**:连续合成直到材料不足或产物满 64(结果数量校验)。 +- 打开工作台 → 3×3 网格 + 输出格 + **27 主背包 + 9 热键栏**(UI还原.md §3.4,与物品栏同一套 `(7,83)` / `(7,141)`)。 +- 网格/背包槽位操作复用统一规则表(物品与背包.md §3、UI与输入.md §4):左键整栈、右键半组/放 1、Shift 网格 ↔ 背包。 +- **Shift+点击输出格 = 批量合成**(`TakeResultAll`):循环合成直到材料不足或背包放不下;单次上限 64 次(防刷)。 +- 关闭工作台:网格余料退回背包,放不下掉落地面。 -## 6. 配方书(可选,后期) +## 6. 配方书 -- 解锁记录存玩家数据(`unlocked_recipes`);发现配方(放入材料)自动解锁。 -- 点击配方 → 自动把网格填满(服务器返回填法)。 +- 工作台右侧按钮打开配方列表:结果图标 + 名称;滚轮翻页;搜索框按结果 id / 中文名过滤。 +- `unlocked_recipes` 存玩家数据:第一次成功合成,或背包已能做出时解锁;未解锁灰显、点击不填格。 +- 已解锁且材料足够时点击走 `CraftingFillRecipe`。 ## 7. 服务器权威与防刷(重点) @@ -97,4 +99,7 @@ | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:配方模型、索引、匹配算法、工作台交互、防刷、踩坑清单 | — | -| (合成界面) | 客户端工作台 3×3 界面落地:右键命中工作台打开(TryOpenCrafting)、网格槽位与手持栈交换/合并(CraftingSwap)、输出格取产物消耗材料(CraftingTakeResult)、关闭退回材料(CloseCrafting);UI 规格按 UI还原.md §3.4(176×166 窗口 + 网格 (30,17) + 输出 (124,35));Shift 批量合成与配方书后置 | — | +| (合成界面) | 客户端工作台 3×3 界面落地:右键命中工作台打开(TryOpenCrafting)、网格槽位与手持栈交换/合并(CraftingSwap)、输出格取产物消耗材料(CraftingTakeResult)、关闭退回材料(CloseCrafting);UI 规格按 UI还原.md §3.4(176×166 窗口 + 网格 (30,17) + 输出 (124,35)) | — | +| 2026-08-18 | §5/§6:工作台补 27 主背包;`TakeResultAll` 批量合成;最小配方书点击填格;物品栏 2×2 复用同一匹配 | — | +| 2026-08-18 | §6:配方书滚动、搜索、`unlocked_recipes` | — | +| 2026-08-19 | §10 旧变更记录去掉「Shift 批量合成与配方书后置」(已于 2026-08-18 落地) | — | diff --git a/文档/场景/挖矿与矿物.md b/文档/场景/挖矿与矿物.md index 44a5e4e9..2b7201a4 100644 --- a/文档/场景/挖矿与矿物.md +++ b/文档/场景/挖矿与矿物.md @@ -44,15 +44,32 @@ func breakTime(block BlockState, tool ItemStack, player *Player) time.Duration { ## 4. 掉落与烧炼 -| 情况 | 行为 | -| --- | --- | -| 普通破坏 | 掉落**矿物本体**(煤/铁原矿/钻石原矿) | -| 精准采集附魔 | 掉落**矿石方块**(可放置回世界) | -| 时运附魔 | 掉落数量 ×(1 + 等级概率),上限保护(防翻倍溢出) | -| 烧炼 | 原矿 → 锭(熔炉,物品与背包.md §6) | +对齐 Java 1.20.5 生存模式、**无精准采集/时运**(附魔后置,预留钩子)。创造模式破坏不掉落。 -- 掉落走 实体系统.md §5(掉落物实体 + 合并 + 上限)。 -- 附魔为**后期功能**:先实现无附魔掉落,预留 `drops` 钩子(掉落表函数),避免返工。 +方块 `drops` 为规则数组(`item` / `min` / `max` / `chance` / `tool` / `required_tier` / `or_else`)。门槛不足(工具种类或等级不够)→ 无掉落。流体与基岩 `no_drop`。 + +| 方块 | 掉落 | 工具门槛 | +| --- | --- | --- | +| stone | cobblestone ×1 | 任意镐,否则无 | +| granite / diorite / andesite | 自身 ×1 | 任意镐 | +| grass_block | dirt ×1 | 无 | +| dirt / sand / oak_log / 木板 / 工作台 / 羊毛 | 自身 ×1 | 无 | +| gravel | 10% flint,否则 gravel | 无 | +| oak_leaves | 5% oak_sapling;0.5% apple | 无(默认不掉叶子) | +| glass / ice / water / lava / bedrock | 无 | — | +| torch / furnace | 自身 ×1 | 熔炉需任意镐 | +| coal_ore | coal ×1 | 任意镐 | +| iron_ore | raw_iron ×1 | 石镐+ | +| gold_ore | raw_gold ×1 | 铁镐+ | +| diamond_ore | diamond ×1 | 铁镐+ | +| redstone_ore | redstone ×4–5 | 铁镐+ | +| iron/gold/diamond_block | 自身 ×1 | 对应镐等级 | +| snow_block | snowball ×4 | 无 | +| obsidian | obsidian ×1 | 钻石镐 | + +- 掉落走 实体系统.md §5。掉落物名称必须在 `items.json` 注册(否则地面广告牌与 HUD 图标都不画)。 +- 精准采集 / 时运为后期:先走本表,预留 `silk_touch_item` / fortune 倍率钩子。 +- 烧炼:原矿 / raw_* → 锭(物品与背包.md §6)。 ## 5. 矿洞体验(照明与安全) @@ -81,3 +98,4 @@ func breakTime(block BlockState, tool ItemStack, player *Player) time.Duration { | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:矿物分布表、工具门槛、掉落/烧炼/附魔、矿洞照明、踩坑清单 | — | +| 2026-08-17 | §4:1.20.5 无附魔掉落表(工具门槛、数量区间、沙砾燧石、树叶概率);掉落物必须注册为物品 | — | diff --git a/文档/场景/方块交互与动画.md b/文档/场景/方块交互与动画.md index 7f335c0b..ae398a14 100644 --- a/文档/场景/方块交互与动画.md +++ b/文档/场景/方块交互与动画.md @@ -23,15 +23,21 @@ ## 3. 破坏流程(进度累积) ```text +按下左键:Attack 一次(命中实体则本帧不挖,播 attack.strong);未命中实体且无准星方块则播 nodamage;有方块则 BeginBreak 按住左键: + 若未在挖 → BeginBreak(对准星方块;创造模式即时破坏) + 若已在挖且准星换块 → 进度重置,对新手 BeginBreak breakProgress += toolSpeed(hardness, 工具) * dt if breakProgress >= 1.0: 发送 destroy 事件 → 世界层替换方块 → 掉落/音效/粒子 + EndBreak;下一 tick 若仍按住则立刻对当前准星再 BeginBreak +松开左键:EndBreak,进度清零 ``` - `hardness` 见方块注册表(设计.md §2.3);工具倍率:锹/镐/斧按材质。 - **服务器权威**:客户端只做表现,服务器重算耗时与结果(防作弊,见 网络协议.md §8)。 - 中途松开/换方块:进度重置。 +- **攻击只在按下边沿**,按住不每 tick 攻击。 ## 4. 破坏动画(crack 阶段) @@ -46,10 +52,10 @@ | --- | --- | | 方块 hardness | 基础破坏时间(秒) | | 工具耐久 | 每次破坏消耗 1 点,0 则损坏 | -| 掉落 | 破坏后由掉落表决定(含精准采集/时运,可后置) | -| 音效/粒子 | 独立于破坏逻辑挂接,避免耦合 | +| 掉落 | 破坏后由掉落表决定(见 挖矿与矿物.md §4;创造模式不掉落) | +| 音效/粒子 | 按方块 `sound_group` 发 `block..break`;挖掘期间每 4 tick 发 `.hit`(含第 0 tick,音频.md §6.2) | -- 破坏瞬间决定掉落物,掉落物进世界实体系统。 +- 破坏瞬间按掉落规则掷骰,掉落物进世界实体系统。门槛不足则方块仍破坏(slow 策略)但不掉落。 ## 6. 放置流程 @@ -105,3 +111,7 @@ type BlockMeta uint8 // 朝向、含水、半数、stage | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:DDA、破坏进度、耐久、crack 动画、状态模型、火把联动、踩坑清单 | — | +| 2026-08-17 | §5:掉落走结构化规则表;音效按 sound_group;创造模式不掉落 | — | +| 2026-08-17 | §3:按住左键挖完一块后立刻续挖准星方块;换目标重置;攻击仅按下边沿 | — | +| 2026-08-18 | §5:挖掘 hit 每 4 tick(对齐 Java destroyTicks) | — | +| 2026-08-18 | §3:挥空 nodamage vs 挖;命中 attack.strong | — | diff --git a/文档/场景/村民系统.md b/文档/场景/村民系统.md index 3b979b80..65a0a4f8 100644 --- a/文档/场景/村民系统.md +++ b/文档/场景/村民系统.md @@ -93,6 +93,7 @@ type BuildTask struct { - 交易表数据驱动:`assets/config/villager_trades.json`(Mod 可扩展)。 - 规则:职业 → 收购物品列表 → 出售物品;每次交易加价(后期)。 - 服务器权威:交易结果服务器计算,防复制刷物品(数量/库存校验)。 +- **会话接入**:`NewSession` 加载交易表;右键村民打开 `screen=12`;`ExecuteTrade` 校验背包数量。建造者生成时填 `Blueprint` 队列,`tickVillager` 调 `BuildTick`,只写入空气(不覆盖玩家放置)。日程 AI / 围攻本轮不做。 ## 8. 踩坑清单 @@ -115,3 +116,4 @@ type BuildTask struct { | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:村庄生成、职业日程、工作行为、建造子系统(特色)、交易、踩坑清单 | — | +| 2026-08-19 | §6–§7:BuildTick / 交易窗 screen=12 接入会话 | — | diff --git a/文档/场景/渲染.md b/文档/场景/渲染.md index a5402525..4944f24c 100644 --- a/文档/场景/渲染.md +++ b/文档/场景/渲染.md @@ -51,7 +51,8 @@ func main() { ### 4.1 植被染色(原版材质包兼容) - 原版材质包的 `grass_block_top`、`oak_leaves` 等是**灰度图**,绿色来自生物群系染色表 `textures/colormap/grass.png`、`foliage.png`;直接采样会得到一片灰世界。 -- 数据驱动:`blocks.json` 中带 `"tint": "grass" | "foliage"` 的方块,其全部纹理键在**图集构建后**乘以染色表颜色(注册表 `TintTextureKeys()` 提供「染色表 → 纹理键」映射)。 +- 数据驱动:`blocks.json` 中带 `"tint": "grass" | "foliage"` 的方块,**仅灰度植被纹理**在图集构建后乘染色表颜色(注册表 `TintTextureKeys()`)。 +- **禁止染色**:被无 tint 方块共享的纹理(如草底面的 `block/dirt`)、以及已着色的 `*_side`(`grass_block_side`)。否则泥土/草侧面整块变绿。 - MVP 实现:取染色表**中心色**(平原绿)整图集统一乘色;**按群系逐顶点着色(温度+湿度采样染色表)后置**,届时改为顶点色/片元 uniform,不再 bake 进图集。 - 顺序要求:染色必须在 `SetAtlas`(mipmap 生成)**之前**完成,否则 mip 链还是灰色。 @@ -71,21 +72,33 @@ type Vertex struct { ### 5.2 覆盖层渲染(破坏裂纹与手持方块) - **破坏裂纹**(方块交互与动画.md §4):对破坏目标方块叠加一个**略外扩**(≈1.004)的单位立方体,六面贴 `block/destroy_stage_N.png`(N = 进度×10,0–9),满亮无光照;`gl.PolygonOffset` 防 z-fighting。裂纹图集独立打包上传(10 阶段)。 -- **第一人称手持**: - - 空手:肤色拳(白羊毛纹理 × 肤色 tint); - - 手持**可放置方块**:视图空间固定位置/尺度的单位立方体(六面 UV 取自选中槽放置方块的 top/down/side); - - 手持**非放置物品**(工具/食物等):实体图集内物品贴图的**小四边形**,轻微倾斜、双面、alpha test(原版手持角度近似),随挥动动画平移。 - - 顶点直接以视图坐标构造,绘制时只乘投影矩阵。 +- **第一人称手持**(原版右手近似,不解析 `display.firstperson_righthand`): + - **手臂始终画出**:人形右臂盒 4×12×4,UV 取玩家皮肤(`entity/player/wide/steve` → `entity/steve`);失败 Warn + 肤色回退。禁止白羊毛立方体当拳头。 + - 空手:只画手臂,视图空间右下、略外旋。 + - 手持**可放置方块**:手臂 + 手中单位立方体(右偏/下沉/绕轴小角度),不再轴对齐贴脸。 + - 手持**非放置物品**:16×16 图标挤出薄片(前后贴图 + 四边像素),挂手臂前端。 + - **挥动**:绕肩下劈再回弹(手臂与物品一起转);打开容器不画 FP 手。 + - 顶点以视图坐标构造,绘制时只乘投影矩阵。 - 二者与区块网格共用顶点格式与着色器,通过 `uOffset`(裂纹盒世界坐标平移)与「视图空间烘焙」(手持)区分。 - GL 铁律沿用 §2:裂纹/手持的 VBO 上传只发生在渲染线程(tick 只算 UV)。 -### 5.3 实体渲染(广告牌 + 动物方块模型) +### 5.3 实体渲染(盒模型 + 掉落物广告牌) -- **动物(牛/猪/羊/鸡)**:方块组合模型(身体 + 头 + 四腿,鸡为身体+头+两腿),贴图取自实体图集内动物皮肤的**子矩形**(头部在纹理左侧、身体在右侧、腿在下部,按原版皮肤布局折算);模型水平朝向相机(始终看到侧面)。顶点每帧动态上传(每只约 150 顶点,数量少无压力)。 -- **僵尸/村民/掉落物**:相机朝向的交叉四边形(脚底锚点 + 高×宽),贴 `entity/*.png`(图集打包,64px tile);**alpha test 开启**——否则纹理透明区域显黑块。 -- 顶点着色器按 `uCamRight/uCamUp` 展开单位四边形(x∈[-0.5,0.5]、y∈[0,1],脚底为原点),`uUVMin/uUVMax` 把 UV 重映射到图集内对应实体格子;满亮不受光照影响。 -- 实体图集与方块图集分离(尺寸/采样不同);广告牌绘制时关闭背面剔除(始终面向相机);动物模型开启背面剔除(方块正常绕序)。 -- 动物按实体 ID 轮换 cow/pig/sheep/chicken 外观(物种系统后置时改为数据驱动)。 +- **动物(牛/猪/羊/鸡)**:盒模型对齐原版 `CowModel` / `PigModel` / `SheepModel` / `ChickenModel`(1.20.5 temperate 皮肤)。每个 `addCuboid(texOffs(u,v), dx,dy,dz)` 按原版六面 UV 岛映射,尺寸单位 1 px = 1/16 方块。 +- **头朝向(与原版四足一致)**:头/嘴在模型 **−Z**,脸在 NORTH 面。`Entity.Yaw = atan2(velX, velZ) + π`,使 **−Z 指向速度**。禁止「始终朝向相机」。头若放在 +Z 而 NORTH 仍在 −Z,会从后方同时看到尾巴和眼睛。 +- **羊皮肤**:身体候选 `entity/sheep/sheep`、`entity/sheep/sheep_temperate`。羊毛层候选 `entity/sheep/sheep_fur`、`entity/sheep/sheep_wool`;**解析失败则不画叠加**(禁止把品红占位当羊毛)。 +- **图集**:实体皮肤按**原始宽高**装箱,禁止把 64×32 拉成 64×64(纵向拉伸会错位全部 UV 岛)。alpha test 开启以剔除皮肤透明区;UV 必须落在不透明岛上,否则整只动物被 `discard`。 +- **缺贴图**:必选皮肤的**全部候选**都 `ResolveTexture` 失败时才写入品红占位并 Warn。候选短名(如 `entity/cow/cow`)在 1.20.5 包中不存在时跳过,不算缺失。可选叠加层(羊毛)不得走占位格。 +- **僵尸/村民**:经典 64×64 **人形盒模型**(头 8³、身 8×12×4、臂/腿 4×12×4),与动物共用 `DrawAnimalModel`。禁止把整张皮肤铺成一张广告牌。 +- **掉落物**:相机朝向的广告牌(脚底锚点 + 高×宽),贴物品图标;**alpha test 开启**。顶点着色器按 `uCamRight/uCamUp` 展开单位四边形(x∈[-0.5,0.5]、y∈[0,1])。 +- 实体图集与方块图集分离;广告牌关闭背面剔除;盒模型开启背面剔除(方块正常绕序)。 +- 动物外观走 `Animal.Species`(0 牛 / 1 猪 / 2 羊 / 3 鸡),与声音/掉落/繁殖同一字段。 + +### 5.4 窗口尺寸与全屏 + +- 窗口 **可缩放 / 可最大化**(`glfw.Resizable = True`)。禁止固定不可改尺寸。 +- **F11** 与 **Alt+Enter** 切换独占全屏(`SetMonitor` 进主屏当前 videomode);再按恢复进入全屏前的窗口位置与尺寸。 +- framebuffer 变化后必须同步:`gl.Viewport`、3D 投影 aspect、UI 正交投影宽高、整数 `guiScale = clamp(min(w/427, h/240), 1, 4)`(见 UI还原.md §3.1)。标题/暂停按钮按当前窗口居中,不得钉死 1280×720。 ## 6. 透明与半透明排序 @@ -162,3 +175,9 @@ type Vertex struct { | --- | --- | --- | | (初始建立) | 初版:GL 亲和、mesh 策略、图集、顶点格式、排序、LOD、踩坑清单 | — | | (光影补充) | 新增 §9 光影系统与光影包:默认光影回退、内容包格式、加载与热重载 | — | +| 2026-08-17 | §5.3:动物盒模型改为原版像素 UV;图集等比装箱;朝向用 Entity.Yaw;缺贴图品红占位 | — | +| 2026-08-17 | §5.3:头在 −Z + yaw+π;羊 temperate/羊毛候选;僵尸/村民人形盒模型。新增 §5.4 可缩放窗口与 F11 全屏 | — | +| 2026-08-17 | §4.1:染色跳过 dirt / 草侧面等共享或已着色纹理 | — | +| 2026-08-18 | §5.3:动物外观改读 `Animal.Species` | — | +| 2026-08-18 | §5.3:实体纹理 Warn 仅在必选候选全缺时 | — | +| 2026-08-18 | §5.2:第一人称手臂盒模型、物品挤出薄片、绕肩挥动 | — | diff --git a/文档/场景/物品与背包.md b/文档/场景/物品与背包.md index 083423dc..5e0c34f3 100644 --- a/文档/场景/物品与背包.md +++ b/文档/场景/物品与背包.md @@ -24,16 +24,25 @@ type ItemStack struct { ## 3. 背包模型与规则表 -- 槽位:36 背包 + 9 热键栏 + 盔甲(可选)+ 副手(可选)。 +- 槽位布局(存档 `slots` 按下标顺序,旧档仅 36 格仍可载入): + - `0–8` 热键栏,`9–35` 主物品区(共 36 格存储,`Add`/`Remove` 只写这里); + - `36–39` 盔甲(头盔 / 胸甲 / 护腿 / 靴子); + - `40` 副手(仅盾)。 +- 物品栏 2×2 合成格是**会话临时网格**(不进 `slots`),关闭 E 时余料退回背包。 - **规则表单一来源**(客户端/服务器共用同一实现,防双端不一致,见 UI与输入.md §4): ```go -func CanPlace(stack ItemStack, slot Slot) bool -func CanMerge(a, b ItemStack) bool // 同 ID + 同 Data + Count 可加 -func Merge(a, b ItemStack) ItemStack // 返回剩余 +func CanPlace(stack ItemStack, slotIndex int) bool // 盔甲/副手按 Equip 限制 +func ClickLeft / ClickRight(slot, held) (newSlot, newHeld) +func SpreadEven / SpreadOne(held, destSlots) +func Gather(name, destHeld, sources) +func ShiftMove(from, destRegion) +func CanMerge(a, b ItemStack) bool +func Merge(limit, a, b) (merged, remain) ``` -- 拖拽/Shift 规则见 UI与输入.md §4(拿起/合并/均分/快捷移动)。 +- 左键整栈、右键半组/放 1、Shift、拖拽均分、双击聚堆见 UI与输入.md §4。 +- 物品 `equip`:`helmet` / `chestplate` / `leggings` / `boots` / `offhand`;亦可按名称后缀推断。 ## 4. 合成 / 熔炼 / 酿造(数据驱动) @@ -42,21 +51,22 @@ func Merge(a, b ItemStack) ItemStack // 返回剩余 - `crafting_shaped`:3×3 有序,pattern + key 匹配; - `crafting_shapeless`:无序,物品集合匹配; - `smelting`:输入物品 → 输出物品(熔炉); - - `brewing`(后期):药水。 + - `brewing`(**后置**:无酿造台方块、无 GUI、无药水配方类型;勿当本轮必做) - 匹配算法:全排列/回退搜索(9 格以内穷举可接受);**配方表启动时构建索引**(按输入物品种类),不在运行时全表扫描。 ## 5. 工具与耐久 - 工具材质:wood/stone/iron/gold/diamond,各自硬度倍率与挖掘速度(联动 方块交互与动画.md §3)。 -- 耐久消耗:破坏方块 -1;攻击 -2(可选);耐久 0 → 损坏(物品消失/残留)。 +- 耐久消耗:破坏方块 -1;攻击 -2;耐久 0 → 损坏(物品消失)。创造模式不扣。仅 `item.Def.Durability > 0` 的工具。 - 工具还影响「破坏耗时」:`time = hardness / (toolSpeed × 倍率)`。 ## 6. 容器方块实体(箱子/熔炉) - 实现 `Tick / Serialize / Deserialize`(设计.md §6.3),随区块保存(存档格式.md §4.3)。 -- 箱子:27 格(双箱 54 格);打开时播放动画(可选,后期)。 -- 熔炉:燃料槽 + 输入槽 + 输出槽;Tick 内燃烧进度与产出(每 tick 少量运算)。 -- 容器内容变化 → 标 `saveDirty` + 通知打开中的 UI(多人同步.md §5)。 +- 箱子:放置创建 `KindChest`(27 格);右键打开 GUI;双箱后置。破坏删除方块实体。 +- 熔炉:燃料槽 + 输入槽 + 输出槽;Tick 内燃烧进度与产出;右键打开 GUI。 +- 会话内方块实体写入 `level.dat.block_entities`(存档格式.md §2);旧档无该字段当空。 +- 容器内容变化 → 标脏 + 通知打开中的 UI(多人同步.md §5)。 ## 7. 序列化与多语言 @@ -84,3 +94,7 @@ func Merge(a, b ItemStack) ItemStack // 返回剩余 | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:物品栈、背包规则表、配方索引、工具耐久、容器方块实体、踩坑清单 | — | +| 2026-08-18 | §3:盔甲 4 + 副手槽、`CanPlace`、左/右/Shift 规则;物品栏 2×2 为会话网格 | — | +| 2026-08-18 | §3/§6:拖拽均分/双击;箱子放置与 GUI;BE 写入 level.dat | — | +| 2026-08-19 | §4:`brewing` 明确标**后置**(无酿造台/GUI/药水配方),避免与现范围缺口混淆 | — | +| 2026-08-19 | §5:破坏 -1 / 攻击 -2 / 归零销毁;创造不扣 | — | diff --git a/文档/场景/物理与碰撞.md b/文档/场景/物理与碰撞.md index 04e91bda..7f2b99dc 100644 --- a/文档/场景/物理与碰撞.md +++ b/文档/场景/物理与碰撞.md @@ -17,7 +17,8 @@ | 状态 | 宽 | 高 | 眼睛高度 | | --- | --- | --- | --- | | 站立 | 0.6 | 1.8 | 1.62 | -| 潜行 | 0.6 | 1.5 | — | +| 潜行 | 0.6 | 1.5 | 1.27 | +| 游泳 | 0.6 | 0.6 | 0.4 | ### 2.2 逐轴移动(核心算法) @@ -41,16 +42,39 @@ for axis in [X, Z, Y]: # 顺序:水平两轴 + 垂直 | 跳跃 | 起跳初速度使起跳高度约 1.25 方块 | | 冲刺 | 水平速度 ×1.3,饥饿消耗 | | 潜行 | 高度 1.5、速度 ×0.3、不掉落边缘 | -| 游泳 | 浮力 + 减速 + 呼吸条 | +| 游泳 | 见 §4:踩水 / 冲刺游泳姿态 + 氧气 | | 飞行 | 无重力,速度可调,潜行下降/跳跃上升 | - 潜行边缘判定:AABB 下沿是否悬空(落地前检查支撑方块)。 -## 4. 液体阻力与浮力 +## 4. 液体、游泳与氧气 -- 在水中:水平速度衰减(乘系数),垂直有浮力,可跳起出水。 -- 在岩浆中:更强减速 + 持续伤害。 -- 液体的碰撞视为非固体(可穿过),但移动受限——用「是否在液体方块内」判定,不是碰撞。 +状态拆分(不可混用一个 `Submerged`): + +| 标志 | 判定 | 用途 | +| --- | --- | --- | +| `InWater` | 脚或躯干在液体方块内 | 阻力、上浮、游泳控制 | +| `EyeInWater` | 眼睛高度所在格为液体 | 氧气、水下雾/蓝叠加 | +| `Swimming` | `InWater` 且冲刺+前进 | 碰撞箱改为 0.6 高 | + +控制(对齐原版 1.13+ 简化集): + +- 水中空格:上浮(不要求落地);潜行:下潜。 +- 冲刺+前进:进入游泳姿态(高 0.6、眼高 0.4、水平加速)。退出游泳时若站立 AABB 卡住则保持矮碰撞箱直到空间足够。 +- 非冲刺为踩水:水平减速,可探头(眼睛出液面即换气)。 + +氧气(呼吸条): + +- 满值 15 秒(300 tick)。`EyeInWater` 时每秒 −1;眼睛出水立即回满。 +- 氧气到 0 后每秒 −2 心(溺水)。HUD 气泡见 UI还原.md §3.4。 +- 着水落地减免摔落伤害(§7)。 + +液体的碰撞视为非固体(可穿过),移动受限用「是否在液体方块内」判定,不是碰撞。岩浆:更强减速 + 持续伤害(后置)。 + +## 4.1 脚步 + +- `walkDist += 本 tick 水平位移 × 0.6`;落地且非水中时,`walkDist` 越过下一个整数播 `block..step`(音频.md §6.2),音量 0.15。 +- 冲刺不改距离阈值(只是更快跨过整数)。水中:入水 `splash`,位移累积 ≥0.8 格 `swim`。 ## 5. 非立方体碰撞 @@ -65,8 +89,9 @@ for axis in [X, Z, Y]: # 顺序:水平两轴 + 垂直 ## 7. 摔落伤害 -- 落地时 `vY` 超过阈值(如 3.0)→ 伤害 = 距离换算。 -- 潜行/着水减免;飞行落地不判定。 +- 离地时累加 `fallDist += max(0, -vy*dt)`;落地或入水清零。 +- 落地(`fallDist >= 3`):有伤害 → `entity.player.big_fall` + 扣血(现公式 `(-vy-16)/4`)+ `entity.player.hurt`;无伤害 → `entity.player.small_fall`。 +- 潜行/着水减免;飞行落地不判定、不播坠落音。 ## 8. 踩坑清单 @@ -88,3 +113,6 @@ for axis in [X, Z, Y]: # 顺序:水平两轴 + 垂直 | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:AABB、逐轴解算、移动模式、液体、非立方体、推挤、踩坑清单 | — | +| 2026-08-17 | §3–§4:InWater/EyeInWater/Swimming 拆分、氧气 15s、冲刺游泳姿态、着水摔落减免 | — | +| 2026-08-18 | §4.1:脚步 walkDist×0.6 跨整数 | — | +| 2026-08-18 | §7:fallDist 与 small_fall / big_fall | — | diff --git a/文档/场景/红石与方块更新.md b/文档/场景/红石与方块更新.md index 343a4dd3..7cf020ab 100644 --- a/文档/场景/红石与方块更新.md +++ b/文档/场景/红石与方块更新.md @@ -36,11 +36,11 @@ type BlockUpdate struct { | 组件 | 行为 | | --- | --- | -| 红石线 | 接收相邻信号 max-1 传递;连接规则(十字/上下) | -| 火把红石 | 15 级信号源;被充能则熄灭(反相器) | -| 拉杆/按钮 | 开关 → 充能相邻方块 | -| 中继器(后期) | 信号增强 + 1–4 tick 延迟 | -| 比较器(后期) | 比较/减法,读取容器内容 | +| 红石线 | 本轮:`redstone_dust`,`SpreadWire` 衰减,功率写入 meta | +| 拉杆 | 本轮:右键切换,开=15 级源 | +| 火把红石 | 后置 | +| 中继器 / 比较器 | 后期 | +| 活塞 | **后置**(§4) | - 充能规则(简化先做「弱充能」):方块被红石线/火把相邻即激活,激活方块能驱动相邻机械(门、活塞、灯)。 - **强充能 vs 弱充能**(后期精确化):写在 ADR 或本文件附录,避免实现一半返工。 @@ -78,3 +78,4 @@ type BlockUpdate struct { | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:更新模型、红石信号、活塞、持久化边界、踩坑清单 | — | +| 2026-08-19 | 本轮接入 Queue + 红石尘 + 拉杆;活塞/火把红石/中继器后置 | — | diff --git a/文档/场景/音频.md b/文档/场景/音频.md index 9f09e259..c5a07158 100644 --- a/文档/场景/音频.md +++ b/文档/场景/音频.md @@ -24,10 +24,11 @@ ## 3. 3D 音效 -- **距离衰减**:`gain = clamp(1 - dist/maxDist)`,指数或线性;maxDist 每音效可配。 -- 声源位置:世界坐标,随玩家位置/朝向实时更新(玩家头朝向用 HRTF 可选)。 -- **声源上限**:同时播放 ≤ 64 个,超出时按「最远/最旧」淘汰(优先级:最近优先)。 -- 空间化:超出衰减范围的音源不发声但保留句柄(复用,避免反复创建)。 +- **距离衰减**(对齐 Java 1.20):线性 `gain = volume * (1 - dist/atten)`;默认 `atten = 16`,可由 `sounds.json` 的 `attenuation_distance` 覆盖。 +- 声源位置:世界坐标;听者带位置 + **朝向 yaw**。 +- **朝向声像**(非 HRTF):声源相对听者的水平向量转到局部坐标,`pan = clamp(localX / dist, -1, 1)`(左负右正),再做左右声道线性平衡。听者自身音(挥击/坠落)`dist≈0` → `pan=0`。 +- **声源上限**:同时播放 ≤ 64 个,超出时按「最远」淘汰。 +- 超出衰减范围:不发声、不入字幕队列。 ## 4. 分类与音量 @@ -49,10 +50,47 @@ - **音频回调/播放线程与主循环分离**:解码与混音绝不在主线程做(IO + CPU)。 - 游戏暂停/切后台:暂停音频输出,恢复时续播(不重播)。 -## 6. 资源加载 +## 6. 资源加载与原版事件 -- `.ogg`(推荐,体积小)/ `.wav`;按需流式加载,`cache/` 缓存解码结果。 -- 循环音效(环境)单独通道,不占音效声源池。 +- 标准素材包路径:`resourcepacks//assets/minecraft/sounds.json` + `assets/minecraft/sounds/.ogg`(如 `mob/sheep/say1.ogg`)。 +- 解析字段:`name` / `weight` / `volume` / `pitch` / `attenuation_distance` / `type`(`file` | `event`,重定向递归上限 4)/ `subtitle`。 +- 选片:按 `weight` 加权随机;有 ogg 必须播文件,禁止再走合成。 +- **引擎不分发 Mojang 音频**。缺失 ogg 时回退程序化合成音(不崩)。 +- `.ogg` 按路径缓存。启动日志打印已解析且至少一条 ogg 的事件数。 + +### 6.1 播放公式(Java 调用点) + +| 事件 | 音量 | 音调 | +| --- | --- | --- | +| `*.hit` | `(1+1)/8` = 0.25 | 0.5 | +| `*.break` / `*.place` | `(1+1)/2` = 1.0 | 0.8 | +| `*.step` | 0.15 | 1.0 | +| `entity.*.ambient/hurt/death` | 1.0 | `(r1-r2)*0.2+1`(约 0.8–1.2) | + +### 6.2 方块 sound group + +方块注册表字段 `sound_group`(stone / wood / gravel / grass / sand / snow / wool / glass)。玩法只发事件,不拼方块名: + +| 动作 | 事件 | +| --- | --- | +| 破坏完成 | `block..break` | +| 放置 | `block..place` | +| 挖掘敲击(每 4 tick,含第 0 tick) | `block..hit` | +| 脚步(`walkDist += 水平位移*0.6`,越过下一整数) | `block..step` | +| 水中移动 | `entity.generic.swim`;入水 `entity.generic.splash` | +| 食用中(每 4 tick) | `entity.generic.eat`;结束 `entity.player.burp` | +| 击中生物 | 先 `entity.player.attack.strong`,再物种 `hurt`;致死 `death` | +| 挥空(按下左键、无实体且无准星方块) | `entity.player.attack.nodamage`(仅按下边沿) | +| 坠落落地 | `fallDist>=3` 无伤害 → `entity.player.small_fall`;有伤害 → `entity.player.big_fall`(+ `entity.player.hurt`) | +| 动物/村民/僵尸闲置 | `entity..ambient`(`ambientSoundTime`,间隔 80) | + +动物走路播脚下 `block..step`(与 Java LivingEntity 一致)。 + +### 6.3 字幕 + +- `Play` 成功且距离 ≤ atten 时入队:`sounds.json` 的 `subtitle` 键。 +- HUD 在准星识别条下方展示(UI还原.md §3.4);同键刷新;最多 4 行;约 3s 淡出。 +- 文案:`zh_cn` → 素材包 `en_us` → 回退事件名。 ## 7. 踩坑清单 @@ -74,3 +112,6 @@ | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版:事件驱动架构、3D 衰减、声源上限、分类音量、库线程、踩坑清单 | — | +| 2026-08-17 | §6:原版 sounds.json/.ogg 加载、sound group 事件表、脚步/食用/击打;正版音频由用户素材包提供 | — | +| 2026-08-18 | §3/§6:衰减 16、加权随机与 event 重定向、Java 播放公式、ambient/hurt/death、字幕入队 | — | +| 2026-08-18 | §3/§6.2:朝向左右平移(非 HRTF);挥空/命中挥击;坠落 small/big_fall | — | diff --git a/文档/存档格式.md b/文档/存档格式.md index a46adbbe..11d7750c 100644 --- a/文档/存档格式.md +++ b/文档/存档格式.md @@ -25,7 +25,8 @@ worlds// | game_mode | string | `survival` / `creative` | | difficulty | string | `peaceful` / `easy` / `normal` / `hard` | | spawn | [int32×3] | 出生点坐标 | -| player | object | 玩家快照(位置、血量、背包) | +| player | object | 玩家快照:`x,y,z,yaw,pitch,health,hunger,selected,slots[...]`;可选 `saturation`(float,饥饿饱和度,缺省 0)、`unlocked_recipes`(配方 id 列表)。`slots` 下标:0–8 热键栏、9–35 主区、36–39 盔甲、40 副手。`exhaustion` 不落盘 | +| block_entities | array | 会话方块实体:`[{x,y,z,kind,slots,burn_ticks,burn_max,cook_ticks}]`;`kind`:`chest` / `furnace`。旧档缺省当空 | | protocol_version | int | 多人:创建时的协议版本(见 网络协议.md §9) | 示例见 设计.md §9.3。 @@ -68,16 +69,18 @@ sections: 16×16×16;缺失 section 视为全空气 ### 4.4 实体 - 列表:`[id, type, pos, vel, data(json)]`;区块卸载时随区块保存或移入 `entities/`。 +- 动物 `data` 必须含 `species`(0 牛 / 1 猪 / 2 羊 / 3 鸡)。当前实现尚未持久化实体,会话内已用该字段。 ## 5. 压缩与校验 - 压缩:zstd 默认(level 3);小块(<64B)可 gzip 或不压缩。 - 每条记录尾部 4B CRC32。 -- 损坏判定:长度越界、CRC 失败 → 丢弃该区块、日志告警,不崩溃。 +- 损坏判定:长度越界、CRC 失败、未知压缩、解压失败 → 该记录视为未保存(`ok=false`),`LoadWorld` 跳过并继续,不中止整世界。 ## 6. 写入与崩溃恢复 -- 全部**写临时文件**(同目录 `.tmp`)→ `fsync` → 原子 `rename`。 +- 全部**写临时文件**(同目录 `.tmp`)→ `fsync` → 原子替换。Windows 上目标已存在时不能直接 `rename`,先把旧文件改名为 `.old` 再替换。 +- Region:每次写入重写整个文件(头部 + 所有已存区块扇区紧密排列),避免旧头被拷进数据区。 - 定期 flush(默认 30s)与关停时全量 flush。 - 脏区块标记:方块 / 实体变更即标记;保存成功后清除。 - 异步保存池;失败重试 3 次,仍失败写入错误日志并标记区块为脏。 @@ -112,3 +115,9 @@ type Migrator interface { | 日期 | 变更 | 作者 | | --- | --- | --- | | (初始建立) | 初版 v1:目录布局、level.dat、region、palette、压缩校验、迁移 | — | +| 2026-08-17 | §2/§6:player 快照字段;Windows 原子替换;region 整文件重写 | — | +| 2026-08-17 | §5:损坏记录视为未保存,LoadWorld 不因单块越界/CRC 失败而整档失败 | — | +| 2026-08-18 | §4.4:实体 data 预告动物 `species` | — | +| 2026-08-18 | §2:player.slots 扩至盔甲/副手(36–40),旧档前 36 格兼容 | — | +| 2026-08-18 | §2:`block_entities`、`player.unlocked_recipes`;`game_mode` 创建/读档生效 | — | +| 2026-08-19 | §2:player 可选 `saturation`;`exhaustion` 不落盘 | — | diff --git a/文档/构建与发布.md b/文档/构建与发布.md index 0dd71607..a18dc5fb 100644 --- a/文档/构建与发布.md +++ b/文档/构建与发布.md @@ -4,12 +4,22 @@ ## 1. 开发构建 +Windows 根目录双击(或命令行): + +```bat +打包-dev.bat +``` + +等价于 `powershell -File scripts/build.ps1`(tags `gl dev`,产物 `bin/mc-client.exe`,并复制 `assets/` `config/` `mods/` `resourcepacks/`)。**不要用 `go run`**:临时目录没有资源,图集会加载失败。 + +`scripts/*.ps1` 必须是 **UTF-8 带 BOM**。Windows PowerShell 5.1(`打包-dev.bat` 在未安装 PowerShell 7 时走这条)按系统 ANSI(中文 Windows 为 GBK)读无 BOM 的 UTF-8,会把多字节汉字误读成引号,整份脚本从第一处中文起解析失败(`意外的标记 "-s"`、`字符串缺少终止符`)。PowerShell 7 / `pwsh` 默认 UTF-8,无 BOM 也能跑。编辑脚本后若 BOM 丢失,用带 BOM 再存一次即可。 + ```sh -go run ./cmd/client # 客户端(开发模式,直接读 assets/) -go run ./cmd/server # 专用服务器 +go run ./cmd/server # 专用服务器(无 GL) ``` - 开发模式不嵌入资源,直接读文件系统,便于改素材即时生效(设计.md §1.2)。 +- **客户端无 CMD**:`build.ps1` 给 `mc-client.exe` 加 `-H windowsgui`(服务器仍是 console)。日志写 `logs/client.log`,启动步骤同时画在 Loading 左下角(UI还原.md §2)。无窗口时的崩溃仍走 `crash.Guard` → `logs/crash.log`。手搓构建未加 `-H` 时,Windows 下启动后 `HideConsole` 兜底。 ## 2. 资源嵌入与切换 @@ -29,7 +39,7 @@ package assets // 发布:go:embed 内嵌 ## 3. 发布构建 ```sh -go build -trimpath -ldflags "-s -w -X main.Version=$VERSION" -o bin/mc-client ./cmd/client +go build -tags gl -trimpath -ldflags "-s -w -H windowsgui -X main.Version=$VERSION" -o bin/mc-client ./cmd/client go build -trimpath -ldflags "-s -w -X main.Version=$VERSION" -o bin/mc-server ./cmd/server ``` @@ -77,7 +87,7 @@ mc-v1.2.0/ - 产品名称:**年糕历险记**(安装包文件名 `年糕历险记-Setup-v<版本>`;产品、快捷方式、卸载项同名)。 - **两条交付路径**: - 1. **NSIS 正式安装器(当前交付)**:`pwsh scripts/make_nsis.ps1 -Version <版本>` 一键构建(内部:`build.ps1` 构建 → 定位 `tools/nsis/` 或 PATH 的 NSIS 3 `makensis` → 编译 `scripts/installer.nsi`)→ 产物 `dist/年糕历险记-Setup-v<版本>.exe`。手动方式:`makensis /DVERSION=<版本> scripts/installer.nsi`(NSIS 3,可放 `tools/nsis/`)。注意 `installer.nsi` 需为 **UTF-8 带 BOM**(NSIS 3 按 BOM 识别编码,无 BOM 会报 `Bad text encoding`;`make_nsis.ps1` 编译前会自动补 BOM)。 + 1. **NSIS 正式安装器(当前交付)**:根目录 `打包-安装包.bat`(可选参数版本号,默认 `0.1.0`),或 `pwsh scripts/make_nsis.ps1 -Version <版本>`。内部:`build.ps1 -Release` → 定位 `tools/nsis/` 或 PATH 的 NSIS 3 `makensis` → 编译 `scripts/installer.nsi` → 产物 `dist/年糕历险记-Setup-v<版本>.exe`。手动方式:`makensis /DVERSION=<版本> scripts/installer.nsi`(NSIS 3,可放 `tools/nsis/`)。注意 `installer.nsi` 需为 **UTF-8 带 BOM**(NSIS 3 按 BOM 识别编码,无 BOM 会报 `Bad text encoding`;`make_nsis.ps1` 编译前会自动补 BOM)。 2. **便携安装包(备选,无 NSIS 环境)**:`pwsh scripts/make_installer.ps1` 生成 `dist/年糕历险记-Setup-v<版本>.zip`,解压后运行 `安装.bat`——全中文交互(选择安装目录、复制文件、可选创建桌面快捷方式「年糕历险记」默认选中、安装完成后可选立即运行游戏默认选中)。 - **安装器选项(全中文向导)**:组件页可选**快捷方式**——「创建桌面快捷方式」「创建开始菜单快捷方式」默认勾选可取消;完成页可选**安装完成后立即运行**默认勾选可取消;开始菜单含「年糕历险记」与「卸载年糕历险记」入口;卸载时保留 `worlds/` 存档与 `logs/` 日志。 - 安装内容:`mc-client.exe`、`mc-server.exe`、`config/`、`mods/`(README)、`resourcepacks/`(默认素材包)。 @@ -102,3 +112,6 @@ mc-v1.2.0/ | (安装包) | 新增 §8:Windows 安装包「年糕历险记」NSIS 中文安装 | — | | (NSIS 交付落地) | NSIS 正式安装器改为当前交付:新增 `scripts/make_nsis.ps1` 一键打包;`installer.nsi` 增加组件页快捷方式选项(桌面/开始菜单,默认勾选)与完成页运行选项(默认勾选);README 补 NSIS 打包说明 | — | | (安装器图标) | 规范:安装器 exe / 卸载器必须用游戏 logo——`make_icon.ps1` 生成多尺寸 `cmd/client/icon.ico`,`installer.nsi` 经 `MUI_ICON` / `MUI_UNICON` 引用(§8) | — | +| 2026-08-17 | 根目录 `打包-dev.bat` / `打包-安装包.bat`;安装包走 `build.ps1 -Release` | — | +| 2026-08-19 | `scripts/*.ps1` 须 UTF-8 BOM,避免 Windows PowerShell 5.1 按 GBK 误读中文引号(§1) | — | +| 2026-08-19 | 客户端 `-H windowsgui`,无用户 CMD;日志走文件 + Loading(§1、§3) | — | diff --git a/文档/索引.md b/文档/索引.md index 06be26cc..26e63cea 100644 --- a/文档/索引.md +++ b/文档/索引.md @@ -105,7 +105,7 @@ | 僵尸 / 敌对生物 / 燃烧 / 村庄围攻 | 场景/僵尸与敌对生物.md | | UI / 拖拽 / Shift 点击 / 多语言 / 键位 / 鼠标捕获 | 场景/UI与输入.md、设计.md §11 | | UI 一比一还原 / 原版界面 / 布局规格 / 截图验收 | 场景/UI还原.md | -| 音频 / 3D 音效 / 声源上限 / 音乐 | 场景/音频.md、设计.md §11.3 | +| 音频 / 3D 音效 / 声源上限 / 音乐 / 字幕 | 场景/音频.md、设计.md §11.3、场景/UI还原.md §3.4 | | 同步 / 插值 / 区块流 / 广播 / 防作弊 / 心跳 | 场景/多人同步.md、网络协议.md、设计.md §10 | | 存档 / diff / 异步保存 / 原子写入 / 迁移 / 崩溃恢复 | 场景/存档与持久化.md、存档格式.md、设计.md §9 | | GC / sync.Pool / pprof / 帧预算 / tick 解耦 / 锁粒度 | 场景/性能与内存.md、设计.md §4.3 | @@ -151,8 +151,9 @@ | 做怪物 / AI / 寻路 | 场景/实体系统.md §3 | | 做刷怪 / 动物生成 | 场景/实体系统.md §4 | | 做掉落物 | 场景/实体系统.md §5 + 场景/物品与背包.md | -| 做物品栏 / 合成台 | 场景/物品与背包.md + 场景/UI与输入.md | +| 做物品栏 / 合成台 | 场景/物品与背包.md + 场景/UI与输入.md + 场景/UI还原.md §3.3 | | 做工作台 / 配方匹配 | 场景/工作台与合成表.md | +| 做物品栏 2×2 / 盔甲槽 / Shift 批量合成 | 场景/物品与背包.md §3 + 场景/工作台与合成表.md §5 + 场景/UI与输入.md §4 | | 做挖矿 / 矿物分布 | 场景/挖矿与矿物.md §2–§3 | | 做附魔 / 精准采集 / 时运 | 场景/挖矿与矿物.md §4 | | 做动物 / 繁殖 / 驯服 | 场景/动物体系.md | @@ -160,7 +161,9 @@ | 做村民建造房子(特色) | 场景/村民系统.md §6(建造子系统) | | 做僵尸 / 敌对生物 | 场景/僵尸与敌对生物.md | | 做僵尸白天燃烧 | 场景/僵尸与敌对生物.md §4 | -| 做箱子 / 熔炉 | 场景/物品与背包.md §6 | +| 做箱子 / 熔炉 | 场景/物品与背包.md §6 + 场景/UI还原.md §3.4 | +| 做创造物品栏 | 场景/UI还原.md §2 + 场景/UI与输入.md §3 | +| 做第一人称手 / 挥动 | 场景/渲染.md §5.2 | | 做拖拽 / Shift 点击 | 场景/UI与输入.md §4 | | 还原原版界面(一比一) | 场景/UI还原.md(界面清单 + 像素布局规格) | | 还原物品栏 / 合成台窗口 | 场景/UI还原.md §2–§3 | @@ -210,3 +213,20 @@ | (玩法与设置) | 渲染.md §5.3 补交叉广告牌与掉落物渲染;方格垂直面绕序修复、鼠标左右反转、音频接入(音频.md §2 事件驱动)、原版掉落与动物攻击(动物体系.md §5)、村庄村民生成(村民系统.md §2)、种子/灵敏度配置与设置界面(UI与输入.md §6)、准星方块名、截图归入 jietu/ | — | | (NSIS 交付落地) | NSIS 正式安装器改为当前交付(构建与发布.md §8):`scripts/make_nsis.ps1` 一键打包,`installer.nsi` 组件页快捷方式选项(桌面/开始菜单)+ 完成页运行选项,README 补 NSIS 打包说明 | — | | (安装器图标) | 构建与发布.md §8 规范:安装器 exe / 卸载器必须用游戏 logo——`make_icon.ps1` 生成多尺寸 `cmd/client/icon.ico`,`installer.nsi` 经 `MUI_ICON` / `MUI_UNICON` 引用 | — | +| 2026-08-17 | 动物盒模型原版 UV(渲染.md §5.3);音效 sounds.json/ogg + sound group(音频.md §6);游泳/氧气(物理与碰撞.md §4、UI还原.md §3.4);1.20.5 无附魔掉落表(挖矿与矿物.md §4) | — | +| 2026-08-17 | 动物头 −Z + yaw+π、羊贴图回退、僵尸/村民人形盒模型(渲染.md §5.3);滚轮原版方向与 F11 全屏(UI与输入.md §6、渲染.md §5.4);按住左键连续破坏(方块交互与动画.md §3) | — | +| 2026-08-17 | 根目录 `打包-dev.bat` / `打包-安装包.bat`(构建与发布.md §1、§8) | — | +| 2026-08-17 | 泥土不再被草染色(渲染.md §4.1);主菜单随窗口缩放+3D 背景(UI还原.md §2);存档退出必写/Windows 原子替换/region 重写(存档与持久化.md、存档格式.md) | — | +| 2026-08-17 | 损坏 region 记录(长度越界/CRC)跳过单块,不中止整世界载入(存档格式.md §5) | — | +| 2026-08-18 | 原版音效公式与时钟、准星识别条(图标+暗底)、附近音效字幕(音频.md §6、UI还原.md §3.4) | — | +| 2026-08-18 | 挥空/命中挥击、坠落音、朝向声像、Animal.Species(音频.md、物理与碰撞.md、动物体系.md) | — | +| 2026-08-18 | 单人选档:选择/新建/删除世界(UI还原.md §2/§3.6、存档与持久化.md §5.1) | — | +| 2026-08-18 | 物品栏 2×2+盔甲/副手、工作台完整背包、左/右/Shift、最小配方书(UI还原.md、物品与背包.md、工作台与合成表.md、UI与输入.md) | — | +| 2026-08-18 | 熔炉/箱子 GUI、拖拽均分与双击、配方书搜索解锁、创造栏、BE 存档、热键栏缩略、第一人称手臂(UI还原/物品与背包/工作台/渲染/存档) | — | +| 2026-08-19 | 文档卫生:路线图 M3/M6/M3.5 去掉过期「后置」、勾选已落地项、新增下一轮四条候选;工作台/UI与输入旧变更记录不再写 Shift/均分后置;酿造在物品与背包.md / 设计.md 标后置;UI还原 创建世界补模式、去掉缺失的 ui-layout-baseline 引用 | — | +| 2026-08-19 | 生存闭环规格:player.saturation(存档格式.md);饥饿消耗/饿死/死亡 screen=11(实体系统.md §6、UI与输入.md、UI还原.md) | — | +| 2026-08-19 | 生态接会话规格:TickAnimal/喂食繁殖/剪羊/牛奶(动物体系.md);交易窗 screen=12 + BuildTick(村民系统.md) | — | +| 2026-08-19 | 世界规则 Tick:随机刻仅草蔓延;水流 0–7;红石尘+拉杆(世界生态.md、红石与方块更新.md) | — | +| 2026-08-19 | 视距卸载 + SavePool(区块管理.md §5、存档与持久化.md §4;仍写整 chunk) | — | +| 2026-08-19 | `scripts/*.ps1` 须 UTF-8 BOM,避免 Windows PowerShell 5.1 按 GBK 误解析(构建与发布.md §1) | — | +| 2026-08-19 | 启动 Loading `screen=13` + 客户端无 CMD;暂停保存/不保存分流(UI还原.md、UI与输入.md、存档与持久化.md、构建与发布.md) | — | diff --git a/文档/设计.md b/文档/设计.md index 6369abec..9269c7c9 100644 --- a/文档/设计.md +++ b/文档/设计.md @@ -1,892 +1,892 @@ -# Go 复刻 Minecraft-like 体素沙盒开发规范 - -> 项目显示名称:**年糕历险记**(游戏内标题、窗口标题、安装包名称统一使用);代码模块名 `mc` 不变。 -> 目标:用 Go 实现可扩展、可 Mod 的类 Minecraft 体素游戏。 -> 本文覆盖目录结构、素材库位置、Mod 加载、动态无限世界生成、渲染、存档、网络与容易遗漏的功能点。 - ---- - -## 1. 项目目录与素材库位置 - -### 1.1 推荐目录结构 - -```text -project/ -├── cmd/ -│ ├── client/ # 客户端入口 -│ └── server/ # 专用服务器入口 -├── internal/ -│ ├── engine/ # 引擎/渲染 -│ ├── world/ # 世界、区块、生成 -│ ├── chunk/ # 区块数据与网格 -│ ├── render/ # 渲染器 -│ ├── physics/ # 碰撞、移动 -│ ├── audio/ # 音频 -│ ├── network/ # 网络协议 -│ ├── mods/ # Mod 加载器/沙箱 -│ ├── assets/ # 资源管理 -│ ├── ui/ # UI 系统 -│ └── config/ # 配置解析 -├── assets/ # 默认素材库(原版布局,可 go:embed) -│ ├── textures/ -│ │ ├── block/ # 方块纹理 -│ │ ├── item/ # 物品纹理 -│ │ ├── entity/ # 实体纹理 -│ │ ├── gui/ # GUI(container/title/sprites/...) -│ │ ├── environment/ # 天空、天气 -│ │ └── font/ # 字体位图(ascii.png 等) -│ ├── models/ -│ │ ├── block/ # 方块模型 JSON -│ │ └── item/ # 物品模型 JSON -│ ├── blockstates/ # 方块状态 JSON -│ ├── atlases/ # 图集源定义(blocks.json / gui.json / items.json ...) -│ ├── font/ # 字体 providers(default.json + include/) -│ ├── lang/ # 多语言 -│ │ ├── en_us.json -│ │ └── zh_cn.json -│ ├── shaders/ -│ │ ├── chunk.vert -│ │ ├── chunk.frag -│ │ └── ... -│ └── config/ -│ ├── blocks.json # 方块注册表 -│ ├── items.json -│ └── recipes.json -├── mods/ # 第三方 Mod 目录 -│ └── example_mod/ -│ ├── manifest.json -│ ├── assets/ -│ │ └── example_mod/ -│ │ ├── textures/ -│ │ ├── sounds/ -│ │ └── models/ -│ ├── scripts/ -│ │ └── init.lua -│ └── data/ -│ ├── blocks.json -│ ├── items.json -│ └── recipes.json -├── resourcepacks/ # 可选资源包,覆盖默认素材 -│ └── MyPack/ -│ ├── pack.json -│ └── assets/ -├── worlds/ # 世界存档 -│ └── NewWorld/ -│ ├── level.dat -│ ├── playerdata/ -│ └── region/ -├── config/ # 客户端/服务器配置 -│ ├── client.yaml -│ └── server.yaml -├── logs/ -├── cache/ # 纹理图集、网格缓存等 -├── scripts/ # 开发脚本 -└── go.mod -``` - -### 1.2 素材库位置说明 - -- **默认素材库**:`assets/`。 - 建议在开发阶段直接读取文件;发布时用 `//go:embed assets` 嵌入二进制。 -- **素材包(材质包)**:`mods/`(主投放点)或 `resourcepacks/`。 - 覆盖默认纹理/模型/语言/音效;运行时扫描,优先级高于默认素材;支持热重载。 - **原生 Minecraft 资源包格式直接支持**(pack.mcmeta + assets/minecraft/...,如 1.20.5 pack_format 34/46),无需转换,解压或 zip 直接放入即被识别为素材包(见 §1.3)。 -- **光影包**:`mods/`(主投放点)。 - 覆盖着色器管线;**未安装光影包时使用系统默认光影**(内置着色器,见 §1.3 与 渲染.md §9)。 -- **Mod 资源**:`mods//assets//...`。 - 优先级:`功能 Mod 资源 > 素材包 > 光影包(仅渲染阶段) > 默认嵌入素材(初版)`。 -- **存档**:`worlds/<世界名>/`。 - 不要把存档放源码目录,便于备份和迁移。 -- **纹理图集缓存**:运行时生成,可放 `cache/atlas_blocks.png`。 - -> 素材包、光影包与功能 Mod 共用**同一套内容包格式**(manifest + type 区分),统一投放到 `mods/`,详见 §1.3。 - ---- - -### 1.3 内容包统一格式:Mod / 素材包 / 光影包 - -**核心原则:光影与材质都「按照 Mod 的形式」嵌入**——三者共用同一个 manifest 格式,用 `type` 字段区分,统一由包加载器扫描。 - -| type | 名称 | 内容 | 未安装时的默认回退 | -| --- | --- | --- | --- | -| `mod` | 功能 Mod | `scripts/` + `data/` + `assets//` | 无 | -| `resource` | 素材包(材质包) | 覆盖默认纹理/模型/语言/音效(`assets/`) | **初版默认材质**(go:embed 嵌入二进制) | -| `shader` | 光影包 | 覆盖着色器管线(`shaders/`) | **系统默认光影**(内置着色器) | - -**统一目录结构**(文件夹或 zip,根目录必须含 `manifest.json`): - -```text -mods/<包名>/ -├── manifest.json # 必须:type / id / version / api_version -├── assets/ # mod / resource:资源覆盖(/...) -├── shaders/ # shader:着色器(chunk.vert / chunk.frag 等,与内置命名一致) -├── scripts/ # 仅 mod:入口脚本 -└── data/ # 仅 mod:数据文件 -``` - -**manifest 示例**(素材包 / 光影包在 Mod 字段基础上新增 `type` 与 `priority`): - -```json -{ - "type": "resource", - "id": "my_texture_pack", - "name": "My Texture Pack", - "version": "1.0.0", - "api_version": 1, - "priority": 0, - "resource_namespace": "minecraft", - "description": "覆盖默认材质" -} -``` - -```json -{ - "type": "shader", - "id": "my_shader_pack", - "name": "My Shader Pack", - "version": "1.0.0", - "api_version": 1, - "priority": 0, - "shaders": { - "chunk": "shaders/chunk.vert,shaders/chunk.frag", - "water": "shaders/water.vert,shaders/water.frag" - } -} -``` - -**投放位置(用户操作)**: - -- **`mods/`:主投放点**。用户下载的素材包、光影包、功能 Mod 直接解压成文件夹(或放 zip)丢进 `mods/`,客户端/服务器启动时统一扫描、按 type 分派。 -- `resourcepacks/`:素材包专属目录,加载器合并扫描。 -- 同类型资源冲突:按 `priority` 大的优先;相同则加载顺序靠后者覆盖,并记日志告警。 - -**原生素材包格式(用户实际投放的主要格式)**: - -- 用户下载的 Minecraft 资源包(根目录 `pack.mcmeta` + `assets//...`)**直接支持**,加载器自动识别为素材包(等效 `type: "resource"`),无需写 manifest.json。 -- 解析原生结构:`atlases/*.json`(图集源)、`blockstates/`、`models/`、`textures/`、`font/`(providers)、`lang/`。 -- 扩展 `manifest.json` 格式仅用于:功能 Mod(`type: "mod"`)与光影包(`type: "shader"`);素材包也可用 manifest 声明 `priority` 等扩展字段(可选)。 -- 当前随仓库提供的默认素材包:`resourcepacks/texture-pack-default1.20.5-26.2/`(原生 1.20.5 结构,可直接复制为嵌入素材基线)。 - -**默认回退(重要,明确写死)**: - -1. **初版材质**:`assets/` 随客户端二进制 `go:embed` 嵌入(§1.2)。**未安装任何素材包时生效**。 -2. **默认光影系统**:引擎内置着色管线(Forward 光照 + 顶点光照/lightmap,见 场景/渲染.md §9)。**未安装光影包时生效**;安装光影包后替换对应着色阶段,开发模式支持热重载;光影包与内置输入约定不兼容时拒绝加载并回退默认。 - ---- - -## 2. 资源与素材规范 - -### 2.1 纹理 - -```text -assets/textures/block/stone.png -assets/textures/block/dirt.png -assets/textures/block/grass_block_top.png -assets/textures/block/grass_block_side.png -assets/textures/item/stick.png -assets/textures/entity/player.png -``` - -- 纹理路径采用**原版命名**:`textures/block/`、`textures/item/`、`textures/entity/`、`textures/gui/`、`textures/environment/`、`textures/font/`(与素材包一致,方便直接使用原版资源包)。 -- 方块纹理建议统一为 `16x16` 或 `32x32`,使用 PNG。 -- 运行时按 `atlases/*.json` 的图集源定义打包成**纹理图集**,减少 draw call。 -- 透明纹理区分: - - `opaque`:不透明 - - `cutout`:alpha test,例如树叶、玻璃 - - `translucent`:半透明,例如水、彩色玻璃 -- 图集需生成 mipmap 与各向异性过滤,避免远处闪烁。 - -### 2.2 方块模型 JSON - -示例 `assets/models/block/grass_block.json`(原版 cube 模型子集): - -```json -{ - "parent": "block/cube", - "textures": { - "particle": "block/dirt", - "down": "block/dirt", - "up": "block/grass_block_top", - "north": "block/grass_block_side", - "south": "block/grass_block_side", - "east": "block/grass_block_side", - "west": "block/grass_block_side" - } -} -``` - -- 模型解析支持原版结构:`parent` 继承、`textures` 变量引用、`elements`/`faces`(先做 cube 与基础元素面,交叉面/旋转后置)。 -- 方块状态映射:`assets/blockstates/.json`(原版格式,variants 按属性选择模型),与 §2.3 注册表的 states 属性表联动。 - -### 2.3 方块状态映射 - -`assets/config/blocks.json`: - -```json -{ - "stone": { - "id": 1, - "hardness": 1.5, - "resistance": 6.0, - "transparent": false, - "light": 0, - "model": "blocks/stone" - }, - "torch": { - "id": 50, - "hardness": 0.0, - "transparent": true, - "light": 14, - "model": "blocks/torch", - "states": { - "facing": ["north", "south", "east", "west", "up"] - } - } -} -``` - -### 2.4 音效与字体 - -- 音效:`assets/sounds/`,推荐 `.ogg` 或 `.wav`。 -- 字体:`assets/fonts/`,使用 TTF 或位图字体;运行时生成字形图集。 -- 语言:`assets/lang/zh_cn.json`,所有 UI 文本必须走语言键。 - ---- - -## 3. Mod 放置与加载 - -### 3.1 Mod 目录 - -每个 Mod 放在 `mods//`,也可以是 `.zip` 包。 -必须包含 `manifest.json`。 - -### 3.2 `manifest.json` 示例 - -```json -{ - "id": "example_mod", - "name": "Example Mod", - "version": "1.0.0", - "api_version": 1, - "entry": "scripts/init.lua", - "depends": [ - {"id": "base_mod", "min_version": "0.5.0"} - ], - "resource_namespace": "example_mod", - "permissions": ["read_assets", "register_blocks", "register_items"], - "description": "A demo mod" -} -``` - -### 3.3 加载流程 - -1. 启动时扫描 `mods/`(+ `resourcepacks/`)目录,识别 `manifest.json` 或 `.zip`。 -2. 校验 `api_version`,不兼容直接跳过并警告。 -3. 解析依赖,按拓扑排序加载。 -4. **按 type 分派**(内容包统一格式,§1.3): - - `mod` → 合并资源命名空间 + 执行脚本/加载数据 + 注册内容; - - `resource` → 合并资源覆盖到资源管理器(素材包); - - `shader` → 注册着色器覆盖(光影包,仅客户端渲染阶段生效)。 -5. 合并资源命名空间到资源管理器(未在步骤 4 处理的)。 -6. 执行入口脚本或加载数据文件。 -7. 注册方块、物品、配方、实体、命令等。 -8. 热重载(完整方案见 场景/热更新.md):仅建议在开发模式开启,正式环境要求重启;素材包/光影包支持热重载;脚本热重载按「注册表 generation 版本化」机制执行(先清理旧代注册/事件,再重新执行入口),需 Mod 遵循该约定。 - -### 3.4 脚本引擎选择 - -> **不要依赖 Go 原生 plugin**。 -> Go plugin 仅支持少数平台,且要求编译器版本完全一致,不适合作为通用 Mod 系统。 - -推荐: - -- **gopher-lua**:轻量 Lua 虚拟机,适合游戏逻辑。 -- **goja**:纯 Go JavaScript 引擎。 -- **expr**:用于配方条件、简单表达式。 -- **WASM**:适合高性能 Mod,但复杂度较高。 - -### 3.5 Mod 沙箱 - -- 限制文件访问:Mod 只能读取自身目录和共享数据目录。 -- 脚本超时控制:例如单个事件处理不超过 5ms。 -- 内存限额:限制注册数量、循环次数。 -- 只允许调用引擎提供的安全 API,不允许直接访问网络、文件系统、unsafe。 -- Mod 冲突检测:相同 ID 或资源键冲突时,后加载者覆盖或报错。 - -### 3.6 Mod 加载伪代码 - -```go -type ModManifest struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - APIVersion int `json:"api_version"` - Entry string `json:"entry"` - Depends []Dependency `json:"depends"` - ResourceNamespace string `json:"resource_namespace"` - Permissions []string `json:"permissions"` -} - -func LoadMod(path string) error { - manifest := readManifest(path) - if manifest.APIVersion != CurrentAPIVersion { - return fmt.Errorf("mod %s API version mismatch", manifest.ID) - } - for _, dep := range manifest.Depends { - if !modLoaded(dep.ID, dep.MinVersion) { - return fmt.Errorf("missing dependency %s", dep.ID) - } - } - assetNS := manifest.ResourceNamespace - loadModAssets(path, assetNS) - if manifest.Entry != "" { - runScript(path, manifest.Entry, sandbox) - } - registerModContent(manifest) - return nil -} -``` - ---- - -## 4. 动态无限世界生成 - -### 4.1 区块与坐标 - -- 区块水平大小:`16x16`。 -- 垂直高度:固定 `256` 或 `384`,建议按 `16x16x16` 的 **section** 管理。 -- 区块坐标使用 `int32`。 -- 世界坐标转区块坐标必须使用 **floorDiv**,处理负数: - -```go -func floorDiv(a, b int32) int32 { - q := a / b - r := a % b - if r < 0 { - q-- - } - return q -} - -cx := floorDiv(worldX, 16) -cz := floorDiv(worldZ, 16) -localX := worldX - cx*16 -localZ := worldZ - cz*16 -``` - -### 4.2 生成管线 - -生成必须 **确定性**,相同种子生成相同世界。 - -建议顺序: - -1. 生成高度图:2D/3D 噪声决定地形高度。 -2. 生成生物群系:温度/湿度噪声。 -3. 填充地表与地下方块。 -4. 生成洞穴、裂隙。 -5. 放置矿物。 -6. 放置结构:树木、村庄、地牢等。 -7. 计算初始光照。 - -示例: - -```go -func generateChunk(seed int64, cx, cz int32) *ChunkData { - ch := NewChunk(cx, cz) - for lz := int32(0); lz < 16; lz++ { - for lx := int32(0); lx < 16; lx++ { - wx := lx + cx*16 - wz := lz + cz*16 - h := terrainHeight(seed, wx, wz) - biome := biomeAt(seed, wx, wz) - fillTerrain(ch, lx, lz, h, biome) - } - } - carveCaves(seed, ch) - placeOres(seed, ch) - placeStructures(seed, ch) - return ch -} -``` - -### 4.3 不卡顿核心策略 - -#### 4.3.1 异步生成 - -- 使用 **goroutine worker pool** 生成区块。 -- Worker 数量建议 `runtime.NumCPU() - 1`,最少 1。 -- 生成完成通过 channel 通知主线程。 - -```go -type ChunkManager struct { - mu sync.Mutex - chunks map[ChunkCoord]*Chunk - priority PriorityQueue - workers chan ChunkJob - generated chan *ChunkData -} -``` - -#### 4.3.2 每帧时间预算 - -- 主线程每帧最多处理 N 个区块任务。 -- 用 `time.Since` 限制总耗时 < 8ms。 -- 超时后剩余任务留到下一帧。 - -```go -func (cm *ChunkManager) Update(playerPos Vec3, budget time.Duration) { - cm.queueChunksAround(playerPos, viewDistance) - for time.Since(start) < budget { - job := cm.priority.Pop() - if job == nil { - break - } - cm.processJob(job) - } -} -``` - -#### 4.3.3 区块状态机 - -```text -unloaded -> queued -> generating -> generated -> meshing -> active -> unloading -> saved -``` - -- 避免重复加载。 -- 状态转换必须原子,防止并发冲突。 - -#### 4.3.4 优先级队列 - -- 按玩家距离排序。 -- 优先加载玩家朝向和移动方向上的区块。 -- 玩家静止时只维持当前视距。 - -#### 4.3.5 流式保存 - -- 区块保存为压缩二进制,按 region 文件管理。 -- 保存操作在独立 goroutine 中异步执行。 -- 使用 `zstd` 或 `gzip` 压缩。 -- 写入临时文件,完成后原子替换,防止崩溃损坏。 - -```go -func (cm *ChunkManager) SaveChunkAsync(c *Chunk) { - cm.saveWG.Add(1) - go func() { - defer cm.saveWG.Done() - data := c.Serialize() - compressed := zstdCompress(data) - writeRegionFile(c.Coord, compressed) - }() -} -``` - -#### 4.3.6 内存池与 GC 压力 - -- 区块数据、顶点切片使用 `sync.Pool` 复用。 -- 避免在热路径频繁分配 `[]byte`、`[]Vertex`。 -- 使用 `unsafe` 或固定大小数组存储方块状态。 -- 方块状态用 `uint16`/`uint32` 位域,不要用 `map[string]any` 存储每个方块。 - -#### 4.3.7 光照传播优化 - -- 天空光按列快速填充。 -- 方块光 BFS 限制在受影响范围内。 -- 未加载相邻区块时延迟光照更新。 -- 光照数据与方块数据分离,存为 4-bit 数组。 - -#### 4.3.8 视距与 LOD - -- 动态视距:根据帧率调整,例如 6~16 区块。 -- 远处区块可用低精度网格,减少顶点数。 -- 区块网格只在方块变化时重建,不每帧重建。 - -#### 4.3.9 实体与随机刻 - -- 实体按区块激活,超出视距冻结。 -- 随机刻每个 section 每 tick 只选少量方块,避免全扫。 -- 生物 AI、液体流动等使用独立 goroutine 或固定 tick 更新。 - ---- - -## 5. 渲染与网格构建 - -### 5.1 面剔除 - -- 只有相邻为透明方块时才生成该面。 -- 被完全不透明方块包围的面不渲染。 - -### 5.2 透明与半透明 - -- 不透明方块:主渲染 pass。 -- 半透明方块(水、玻璃、树叶):单独 pass,按距离从远到近排序。 -- 树叶用 `cutout` alpha test,避免排序。 - -### 5.3 顶点格式 - -建议: - -```go -type Vertex struct { - X, Y, Z float32 - U, V float32 - R, G, B, A uint8 - SkyLight, BlockLight uint8 -} -``` - -- 使用 VAO/VBO,每个 chunk 一个 mesh。 -- 同材质合并 draw call。 - -### 5.4 纹理图集 - -- 所有方块纹理打包成一张大图。 -- UV 使用图集偏移,避免相邻纹理渗色。 -- 开启 mipmap 与各向异性过滤。 - -### 5.5 动态方块纹理 - -- 水、岩浆、火把等通过 uniform 时间偏移或 texture array 实现动画。 -- 不要在 CPU 每帧重传纹理。 - -### 5.6 视锥剔除 - -- 每个 chunk 有 AABB。 -- 每帧只提交视锥内的 chunk。 -- 使用 `gl.CullFace` 或 CPU 视锥裁剪。 - ---- - -## 6. 方块、物品与方块实体 - -### 6.1 方块注册表 - -- 使用稳定字符串 ID 映射到内部数字 ID。 -- 序列化时保存字符串 ID,避免版本变化。 -- 方块状态用位域或小型 `struct`,不要用大 `map`。 - -```go -type Block struct { - ID uint16 - Name string - Hardness float32 - Resistance float32 - Light uint8 - Transparent bool - Collidable bool - Model string - States map[string][]string -} -``` - -### 6.2 物品与配方 - -- 配方数据驱动:`assets/config/recipes.json`。 -- 支持有序/无序合成、熔炼、酿造。 -- 工具属性、耐久、堆叠上限。 - -```json -{ - "type": "crafting_shaped", - "pattern": ["XXX", " X ", " X "], - "key": {"X": "stick"}, - "result": {"item": "torch", "count": 4} -} -``` - -### 6.3 方块实体 - -- 箱子、熔炉等需要额外数据,必须实现: - - `Tick()` - - `Serialize()` - - `Deserialize()` -- 方块实体随区块保存/加载。 - -### 6.4 方块更新 - -- 方块改变时通知相邻方块。 -- 使用队列延迟更新,防止无限递归。 -- 红石、活塞、水等方块更新必须有最大迭代限制。 - ---- - -## 7. 物理与碰撞 - -### 7.1 玩家碰撞 - -- 玩家 AABB:宽 `0.6`,高 `1.8`,眼睛高度 `1.62`。 -- 潜行高度 `1.5`,宽度不变。 -- 逐轴移动并检查碰撞,避免穿墙。 - -### 7.2 非立方体碰撞 - -- 栅栏、台阶、雪层、楼梯等需要自定义碰撞形状。 -- 形状可用多个 AABB 列表。 - -### 7.3 重力与运动 - -- 重力加速度、跳跃速度、摔落伤害。 -- 水、岩浆中的浮力与减速。 -- 飞行、鞘翅等特殊移动模式。 - ---- - -## 8. 光照系统 - -### 8.1 天空光 - -- 从最高方块向下传播,每层减 1。 -- 部分透明方块(如树叶、水)会有额外衰减。 - -### 8.2 方块光 - -- 光源亮度 0~15。 -- BFS 传播,每经过一个方块减 1。 -- 跨区块更新需要加载相邻区块。 - -### 8.3 顶点光照 - -- 每个面四个顶点的光照分别计算。 -- 支持平滑光照,避免硬边。 - ---- - -## 9. 存档与序列化 - -### 9.1 区块数据 - -- 建议使用 palette 压缩方块状态。 -- 实体和方块实体使用 JSON/NBT。 -- 存档文件使用 `zstd` 或 `gzip` 压缩。 - -### 9.2 Region 文件 - -- 每个 region 文件包含 `32x32` 个区块。 -- 文件头记录每个区块的偏移和大小。 -- 崩溃恢复:写入临时文件后原子替换。 - -### 9.3 世界元数据 - -`worlds//level.dat`: - -```json -{ - "version": 1, - "seed": 123456789, - "player": { - "x": 0, "y": 64, "z": 0, - "health": 20, - "inventory": [] - }, - "time": 12000, - "weather": "clear", - "game_mode": "survival" -} -``` - -### 9.4 版本迁移 - -- 存档包含 `version` 字段。 -- 启动时检测旧版本,执行迁移并备份。 -- 不要破坏旧存档,先备份再迁移。 - ---- - -## 10. 网络与多人 - -### 10.1 协议设计 - -- 消息类型: - - 握手、登录 - - 区块数据 - - 方块更新 - - 实体移动 - - 聊天 - - 背包同步 - - 音效/粒子 -- 区块数据压缩后发送。 -- 服务器权威:方块破坏、实体 AI、物品由服务器决定。 - -### 10.2 同步优化 - -- 实体位置 20Hz 同步。 -- 方块变化立即广播,但限制每 tick 发送量。 -- 只发送视距内区块,按距离分包。 - -### 10.3 防作弊基础 - -- 移动速度校验。 -- 挖掘速度与距离检查。 -- 物品 ID 合法性校验。 - ---- - -## 11. UI、输入与音频 - -### 11.1 UI - -- 物品栏、合成、熔炉、容器界面。 -- 支持拖拽、Shift 点击快捷移动。 -- 多语言文本必须从语言文件读取。 - -### 11.2 输入 - -- 可配置键位。 -- 鼠标捕获与释放。 -- 窗口焦点暂停游戏。 - -### 11.3 音频 - -- 3D 音效衰减。 -- 限制同时播放的音源数量。 -- 背景音乐与环境音。 - ---- - -## 12. 容易遗漏的功能点清单 - -以下功能在复刻时经常被遗漏,建议在规划阶段就纳入: - -### 12.1 世界与方块 - -- [ ] 半透明方块渲染排序 -- [ ] 水/岩浆流动逻辑、源方块与流方块 -- [ ] 面剔除与相邻方块更新导致的面重建 -- [ ] 光照更新跨区块延迟 -- [ ] 方块随机刻:作物生长、火传播、冰融化 -- [ ] 红石/活塞延迟与方块推动 -- [ ] 方块实体 Tick 与保存 -- [ ] 生物群系过渡避免突变 -- [ ] 洞穴与结构生成避开液体/基岩 -- [ ] 矿物分布按深度与生物群系 -- [ ] 世界边界与出生点保护 -- [ ] 传送门/维度切换 - -### 12.2 实体与玩家 - -- [ ] 玩家潜行、冲刺、游泳、飞行状态 -- [ ] 摔落伤害与饥饿系统 -- [ ] 生物 AI 寻路 -- [ ] 生物生成/消失规则 -- [ ] 实体碰撞推挤 -- [ ] 玩家重生点 -- [ ] 经验与附魔(如果目标支持) - -### 12.3 渲染与资源 - -- [ ] 纹理图集与 mipmap -- [ ] 动态纹理(水、火把) -- [ ] 视锥剔除与 LOD -- [ ] 资源包热重载 -- [ ] 多语言支持 -- [ ] 粒子系统 -- [ ] 天空盒、云、太阳/月亮渲染 - -### 12.4 存档与配置 - -- [ ] 版本迁移与备份 -- [ ] 崩溃自动保存与恢复 -- [ ] 区块卸载时保存实体 -- [ ] 配置文件格式校验 -- [ ] 日志分级与归档 - -### 12.5 性能与稳定性 - -- [ ] 浮点精度问题:世界原点重定位 -- [ ] 内存池与 `sync.Pool` -- [ ] 区块加载优先级队列 -- [ ] 每帧时间预算 -- [ ] Race detector 检查并发 -- [ ] pprof 性能分析 -- [ ] 防止 goroutine 泄漏 - -### 12.6 多人 - -- [ ] 玩家背包同步 -- [ ] 区块数据分包 -- [ ] 聊天与命令系统 -- [ ] 服务器发现与心跳 -- [ ] 防作弊基础校验 - ---- - -## 13. 开发流程与测试 - -### 13.1 单元测试 - -- 噪声函数确定性 -- 坐标转换(负数) -- 区块序列化/反序列化 -- 光照传播 -- 物品背包逻辑 - -### 13.2 集成测试 - -- 生成 100x100 区块世界,验证无空洞 -- 加载/保存循环测试 -- Mod 加载沙箱测试 - -### 13.3 性能测试 - -- 使用 `go test -bench` 测试区块生成、网格构建 -- 使用 `pprof` 分析 CPU/内存 -- 使用 `go run -race` 检查并发问题 - ---- - -## 14. 配置示例 - -### 14.1 `config/server.yaml` - -```yaml -server: - port: 25565 - max_players: 20 - view_distance: 10 - tick_rate: 20 -world: - seed: 123456789 - name: "NewWorld" - generator: - biome_scale: 0.005 - height_scale: 0.01 - cave_density: 0.1 -mods: - enabled: true - directory: "mods" - allow_scripts: true - script_timeout_ms: 5 -``` - -### 14.2 `config/client.yaml` - -```yaml -client: - window_width: 1280 - window_height: 720 - window_title: "年糕历险记" # 窗口与安装包统一中文名 - vsync: true - fov: 70 - render_distance: 12 - language: "zh_cn" # en_us / zh_cn - graphics: - smooth_lighting: true - mipmap: true - anisotropic: 4 - audio: - volume: 1.0 - music: true -``` - ---- - -## 15. 总结 - -本规范覆盖了 Go 复刻类 Minecraft 游戏的核心结构与容易遗漏的细节。 -关键原则: - -1. **资源与代码分离**:素材放 `assets/`,Mod 放 `mods/`,存档放 `worlds/`。 -2. **Mod 不依赖 Go plugin**:使用 Lua/JS 脚本 + 数据驱动。 -3. **无限世界必须异步生成**:worker pool + 时间预算 + 优先级队列。 -4. **光照、存档、渲染都必须是流式**,避免主线程卡顿。 -5. **从第一天就设计版本迁移与崩溃恢复**。 - +# Go 复刻 Minecraft-like 体素沙盒开发规范 + +> 项目显示名称:**年糕历险记**(游戏内标题、窗口标题、安装包名称统一使用);代码模块名 `mc` 不变。 +> 目标:用 Go 实现可扩展、可 Mod 的类 Minecraft 体素游戏。 +> 本文覆盖目录结构、素材库位置、Mod 加载、动态无限世界生成、渲染、存档、网络与容易遗漏的功能点。 + +--- + +## 1. 项目目录与素材库位置 + +### 1.1 推荐目录结构 + +```text +project/ +├── cmd/ +│ ├── client/ # 客户端入口 +│ └── server/ # 专用服务器入口 +├── internal/ +│ ├── engine/ # 引擎/渲染 +│ ├── world/ # 世界、区块、生成 +│ ├── chunk/ # 区块数据与网格 +│ ├── render/ # 渲染器 +│ ├── physics/ # 碰撞、移动 +│ ├── audio/ # 音频 +│ ├── network/ # 网络协议 +│ ├── mods/ # Mod 加载器/沙箱 +│ ├── assets/ # 资源管理 +│ ├── ui/ # UI 系统 +│ └── config/ # 配置解析 +├── assets/ # 默认素材库(原版布局,可 go:embed) +│ ├── textures/ +│ │ ├── block/ # 方块纹理 +│ │ ├── item/ # 物品纹理 +│ │ ├── entity/ # 实体纹理 +│ │ ├── gui/ # GUI(container/title/sprites/...) +│ │ ├── environment/ # 天空、天气 +│ │ └── font/ # 字体位图(ascii.png 等) +│ ├── models/ +│ │ ├── block/ # 方块模型 JSON +│ │ └── item/ # 物品模型 JSON +│ ├── blockstates/ # 方块状态 JSON +│ ├── atlases/ # 图集源定义(blocks.json / gui.json / items.json ...) +│ ├── font/ # 字体 providers(default.json + include/) +│ ├── lang/ # 多语言 +│ │ ├── en_us.json +│ │ └── zh_cn.json +│ ├── shaders/ +│ │ ├── chunk.vert +│ │ ├── chunk.frag +│ │ └── ... +│ └── config/ +│ ├── blocks.json # 方块注册表 +│ ├── items.json +│ └── recipes.json +├── mods/ # 第三方 Mod 目录 +│ └── example_mod/ +│ ├── manifest.json +│ ├── assets/ +│ │ └── example_mod/ +│ │ ├── textures/ +│ │ ├── sounds/ +│ │ └── models/ +│ ├── scripts/ +│ │ └── init.lua +│ └── data/ +│ ├── blocks.json +│ ├── items.json +│ └── recipes.json +├── resourcepacks/ # 可选资源包,覆盖默认素材 +│ └── MyPack/ +│ ├── pack.json +│ └── assets/ +├── worlds/ # 世界存档 +│ └── NewWorld/ +│ ├── level.dat +│ ├── playerdata/ +│ └── region/ +├── config/ # 客户端/服务器配置 +│ ├── client.yaml +│ └── server.yaml +├── logs/ +├── cache/ # 纹理图集、网格缓存等 +├── scripts/ # 开发脚本 +└── go.mod +``` + +### 1.2 素材库位置说明 + +- **默认素材库**:`assets/`。 + 建议在开发阶段直接读取文件;发布时用 `//go:embed assets` 嵌入二进制。 +- **素材包(材质包)**:`mods/`(主投放点)或 `resourcepacks/`。 + 覆盖默认纹理/模型/语言/音效;运行时扫描,优先级高于默认素材;支持热重载。 + **原生 Minecraft 资源包格式直接支持**(pack.mcmeta + assets/minecraft/...,如 1.20.5 pack_format 34/46),无需转换,解压或 zip 直接放入即被识别为素材包(见 §1.3)。 +- **光影包**:`mods/`(主投放点)。 + 覆盖着色器管线;**未安装光影包时使用系统默认光影**(内置着色器,见 §1.3 与 渲染.md §9)。 +- **Mod 资源**:`mods//assets//...`。 + 优先级:`功能 Mod 资源 > 素材包 > 光影包(仅渲染阶段) > 默认嵌入素材(初版)`。 +- **存档**:`worlds/<世界名>/`。 + 不要把存档放源码目录,便于备份和迁移。 +- **纹理图集缓存**:运行时生成,可放 `cache/atlas_blocks.png`。 + +> 素材包、光影包与功能 Mod 共用**同一套内容包格式**(manifest + type 区分),统一投放到 `mods/`,详见 §1.3。 + +--- + +### 1.3 内容包统一格式:Mod / 素材包 / 光影包 + +**核心原则:光影与材质都「按照 Mod 的形式」嵌入**——三者共用同一个 manifest 格式,用 `type` 字段区分,统一由包加载器扫描。 + +| type | 名称 | 内容 | 未安装时的默认回退 | +| --- | --- | --- | --- | +| `mod` | 功能 Mod | `scripts/` + `data/` + `assets//` | 无 | +| `resource` | 素材包(材质包) | 覆盖默认纹理/模型/语言/音效(`assets/`) | **初版默认材质**(go:embed 嵌入二进制) | +| `shader` | 光影包 | 覆盖着色器管线(`shaders/`) | **系统默认光影**(内置着色器) | + +**统一目录结构**(文件夹或 zip,根目录必须含 `manifest.json`): + +```text +mods/<包名>/ +├── manifest.json # 必须:type / id / version / api_version +├── assets/ # mod / resource:资源覆盖(/...) +├── shaders/ # shader:着色器(chunk.vert / chunk.frag 等,与内置命名一致) +├── scripts/ # 仅 mod:入口脚本 +└── data/ # 仅 mod:数据文件 +``` + +**manifest 示例**(素材包 / 光影包在 Mod 字段基础上新增 `type` 与 `priority`): + +```json +{ + "type": "resource", + "id": "my_texture_pack", + "name": "My Texture Pack", + "version": "1.0.0", + "api_version": 1, + "priority": 0, + "resource_namespace": "minecraft", + "description": "覆盖默认材质" +} +``` + +```json +{ + "type": "shader", + "id": "my_shader_pack", + "name": "My Shader Pack", + "version": "1.0.0", + "api_version": 1, + "priority": 0, + "shaders": { + "chunk": "shaders/chunk.vert,shaders/chunk.frag", + "water": "shaders/water.vert,shaders/water.frag" + } +} +``` + +**投放位置(用户操作)**: + +- **`mods/`:主投放点**。用户下载的素材包、光影包、功能 Mod 直接解压成文件夹(或放 zip)丢进 `mods/`,客户端/服务器启动时统一扫描、按 type 分派。 +- `resourcepacks/`:素材包专属目录,加载器合并扫描。 +- 同类型资源冲突:按 `priority` 大的优先;相同则加载顺序靠后者覆盖,并记日志告警。 + +**原生素材包格式(用户实际投放的主要格式)**: + +- 用户下载的 Minecraft 资源包(根目录 `pack.mcmeta` + `assets//...`)**直接支持**,加载器自动识别为素材包(等效 `type: "resource"`),无需写 manifest.json。 +- 解析原生结构:`atlases/*.json`(图集源)、`blockstates/`、`models/`、`textures/`、`font/`(providers)、`lang/`。 +- 扩展 `manifest.json` 格式仅用于:功能 Mod(`type: "mod"`)与光影包(`type: "shader"`);素材包也可用 manifest 声明 `priority` 等扩展字段(可选)。 +- 当前随仓库提供的默认素材包:`resourcepacks/texture-pack-default1.20.5-26.2/`(原生 1.20.5 结构,可直接复制为嵌入素材基线)。 + +**默认回退(重要,明确写死)**: + +1. **初版材质**:`assets/` 随客户端二进制 `go:embed` 嵌入(§1.2)。**未安装任何素材包时生效**。 +2. **默认光影系统**:引擎内置着色管线(Forward 光照 + 顶点光照/lightmap,见 场景/渲染.md §9)。**未安装光影包时生效**;安装光影包后替换对应着色阶段,开发模式支持热重载;光影包与内置输入约定不兼容时拒绝加载并回退默认。 + +--- + +## 2. 资源与素材规范 + +### 2.1 纹理 + +```text +assets/textures/block/stone.png +assets/textures/block/dirt.png +assets/textures/block/grass_block_top.png +assets/textures/block/grass_block_side.png +assets/textures/item/stick.png +assets/textures/entity/player.png +``` + +- 纹理路径采用**原版命名**:`textures/block/`、`textures/item/`、`textures/entity/`、`textures/gui/`、`textures/environment/`、`textures/font/`(与素材包一致,方便直接使用原版资源包)。 +- 方块纹理建议统一为 `16x16` 或 `32x32`,使用 PNG。 +- 运行时按 `atlases/*.json` 的图集源定义打包成**纹理图集**,减少 draw call。 +- 透明纹理区分: + - `opaque`:不透明 + - `cutout`:alpha test,例如树叶、玻璃 + - `translucent`:半透明,例如水、彩色玻璃 +- 图集需生成 mipmap 与各向异性过滤,避免远处闪烁。 + +### 2.2 方块模型 JSON + +示例 `assets/models/block/grass_block.json`(原版 cube 模型子集): + +```json +{ + "parent": "block/cube", + "textures": { + "particle": "block/dirt", + "down": "block/dirt", + "up": "block/grass_block_top", + "north": "block/grass_block_side", + "south": "block/grass_block_side", + "east": "block/grass_block_side", + "west": "block/grass_block_side" + } +} +``` + +- 模型解析支持原版结构:`parent` 继承、`textures` 变量引用、`elements`/`faces`(先做 cube 与基础元素面,交叉面/旋转后置)。 +- 方块状态映射:`assets/blockstates/.json`(原版格式,variants 按属性选择模型),与 §2.3 注册表的 states 属性表联动。 + +### 2.3 方块状态映射 + +`assets/config/blocks.json`: + +```json +{ + "stone": { + "id": 1, + "hardness": 1.5, + "resistance": 6.0, + "transparent": false, + "light": 0, + "model": "blocks/stone" + }, + "torch": { + "id": 50, + "hardness": 0.0, + "transparent": true, + "light": 14, + "model": "blocks/torch", + "states": { + "facing": ["north", "south", "east", "west", "up"] + } + } +} +``` + +### 2.4 音效与字体 + +- 音效:`assets/sounds/`,推荐 `.ogg` 或 `.wav`。 +- 字体:`assets/fonts/`,使用 TTF 或位图字体;运行时生成字形图集。 +- 语言:`assets/lang/zh_cn.json`,所有 UI 文本必须走语言键。 + +--- + +## 3. Mod 放置与加载 + +### 3.1 Mod 目录 + +每个 Mod 放在 `mods//`,也可以是 `.zip` 包。 +必须包含 `manifest.json`。 + +### 3.2 `manifest.json` 示例 + +```json +{ + "id": "example_mod", + "name": "Example Mod", + "version": "1.0.0", + "api_version": 1, + "entry": "scripts/init.lua", + "depends": [ + {"id": "base_mod", "min_version": "0.5.0"} + ], + "resource_namespace": "example_mod", + "permissions": ["read_assets", "register_blocks", "register_items"], + "description": "A demo mod" +} +``` + +### 3.3 加载流程 + +1. 启动时扫描 `mods/`(+ `resourcepacks/`)目录,识别 `manifest.json` 或 `.zip`。 +2. 校验 `api_version`,不兼容直接跳过并警告。 +3. 解析依赖,按拓扑排序加载。 +4. **按 type 分派**(内容包统一格式,§1.3): + - `mod` → 合并资源命名空间 + 执行脚本/加载数据 + 注册内容; + - `resource` → 合并资源覆盖到资源管理器(素材包); + - `shader` → 注册着色器覆盖(光影包,仅客户端渲染阶段生效)。 +5. 合并资源命名空间到资源管理器(未在步骤 4 处理的)。 +6. 执行入口脚本或加载数据文件。 +7. 注册方块、物品、配方、实体、命令等。 +8. 热重载(完整方案见 场景/热更新.md):仅建议在开发模式开启,正式环境要求重启;素材包/光影包支持热重载;脚本热重载按「注册表 generation 版本化」机制执行(先清理旧代注册/事件,再重新执行入口),需 Mod 遵循该约定。 + +### 3.4 脚本引擎选择 + +> **不要依赖 Go 原生 plugin**。 +> Go plugin 仅支持少数平台,且要求编译器版本完全一致,不适合作为通用 Mod 系统。 + +推荐: + +- **gopher-lua**:轻量 Lua 虚拟机,适合游戏逻辑。 +- **goja**:纯 Go JavaScript 引擎。 +- **expr**:用于配方条件、简单表达式。 +- **WASM**:适合高性能 Mod,但复杂度较高。 + +### 3.5 Mod 沙箱 + +- 限制文件访问:Mod 只能读取自身目录和共享数据目录。 +- 脚本超时控制:例如单个事件处理不超过 5ms。 +- 内存限额:限制注册数量、循环次数。 +- 只允许调用引擎提供的安全 API,不允许直接访问网络、文件系统、unsafe。 +- Mod 冲突检测:相同 ID 或资源键冲突时,后加载者覆盖或报错。 + +### 3.6 Mod 加载伪代码 + +```go +type ModManifest struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + APIVersion int `json:"api_version"` + Entry string `json:"entry"` + Depends []Dependency `json:"depends"` + ResourceNamespace string `json:"resource_namespace"` + Permissions []string `json:"permissions"` +} + +func LoadMod(path string) error { + manifest := readManifest(path) + if manifest.APIVersion != CurrentAPIVersion { + return fmt.Errorf("mod %s API version mismatch", manifest.ID) + } + for _, dep := range manifest.Depends { + if !modLoaded(dep.ID, dep.MinVersion) { + return fmt.Errorf("missing dependency %s", dep.ID) + } + } + assetNS := manifest.ResourceNamespace + loadModAssets(path, assetNS) + if manifest.Entry != "" { + runScript(path, manifest.Entry, sandbox) + } + registerModContent(manifest) + return nil +} +``` + +--- + +## 4. 动态无限世界生成 + +### 4.1 区块与坐标 + +- 区块水平大小:`16x16`。 +- 垂直高度:固定 `256` 或 `384`,建议按 `16x16x16` 的 **section** 管理。 +- 区块坐标使用 `int32`。 +- 世界坐标转区块坐标必须使用 **floorDiv**,处理负数: + +```go +func floorDiv(a, b int32) int32 { + q := a / b + r := a % b + if r < 0 { + q-- + } + return q +} + +cx := floorDiv(worldX, 16) +cz := floorDiv(worldZ, 16) +localX := worldX - cx*16 +localZ := worldZ - cz*16 +``` + +### 4.2 生成管线 + +生成必须 **确定性**,相同种子生成相同世界。 + +建议顺序: + +1. 生成高度图:2D/3D 噪声决定地形高度。 +2. 生成生物群系:温度/湿度噪声。 +3. 填充地表与地下方块。 +4. 生成洞穴、裂隙。 +5. 放置矿物。 +6. 放置结构:树木、村庄、地牢等。 +7. 计算初始光照。 + +示例: + +```go +func generateChunk(seed int64, cx, cz int32) *ChunkData { + ch := NewChunk(cx, cz) + for lz := int32(0); lz < 16; lz++ { + for lx := int32(0); lx < 16; lx++ { + wx := lx + cx*16 + wz := lz + cz*16 + h := terrainHeight(seed, wx, wz) + biome := biomeAt(seed, wx, wz) + fillTerrain(ch, lx, lz, h, biome) + } + } + carveCaves(seed, ch) + placeOres(seed, ch) + placeStructures(seed, ch) + return ch +} +``` + +### 4.3 不卡顿核心策略 + +#### 4.3.1 异步生成 + +- 使用 **goroutine worker pool** 生成区块。 +- Worker 数量建议 `runtime.NumCPU() - 1`,最少 1。 +- 生成完成通过 channel 通知主线程。 + +```go +type ChunkManager struct { + mu sync.Mutex + chunks map[ChunkCoord]*Chunk + priority PriorityQueue + workers chan ChunkJob + generated chan *ChunkData +} +``` + +#### 4.3.2 每帧时间预算 + +- 主线程每帧最多处理 N 个区块任务。 +- 用 `time.Since` 限制总耗时 < 8ms。 +- 超时后剩余任务留到下一帧。 + +```go +func (cm *ChunkManager) Update(playerPos Vec3, budget time.Duration) { + cm.queueChunksAround(playerPos, viewDistance) + for time.Since(start) < budget { + job := cm.priority.Pop() + if job == nil { + break + } + cm.processJob(job) + } +} +``` + +#### 4.3.3 区块状态机 + +```text +unloaded -> queued -> generating -> generated -> meshing -> active -> unloading -> saved +``` + +- 避免重复加载。 +- 状态转换必须原子,防止并发冲突。 + +#### 4.3.4 优先级队列 + +- 按玩家距离排序。 +- 优先加载玩家朝向和移动方向上的区块。 +- 玩家静止时只维持当前视距。 + +#### 4.3.5 流式保存 + +- 区块保存为压缩二进制,按 region 文件管理。 +- 保存操作在独立 goroutine 中异步执行。 +- 使用 `zstd` 或 `gzip` 压缩。 +- 写入临时文件,完成后原子替换,防止崩溃损坏。 + +```go +func (cm *ChunkManager) SaveChunkAsync(c *Chunk) { + cm.saveWG.Add(1) + go func() { + defer cm.saveWG.Done() + data := c.Serialize() + compressed := zstdCompress(data) + writeRegionFile(c.Coord, compressed) + }() +} +``` + +#### 4.3.6 内存池与 GC 压力 + +- 区块数据、顶点切片使用 `sync.Pool` 复用。 +- 避免在热路径频繁分配 `[]byte`、`[]Vertex`。 +- 使用 `unsafe` 或固定大小数组存储方块状态。 +- 方块状态用 `uint16`/`uint32` 位域,不要用 `map[string]any` 存储每个方块。 + +#### 4.3.7 光照传播优化 + +- 天空光按列快速填充。 +- 方块光 BFS 限制在受影响范围内。 +- 未加载相邻区块时延迟光照更新。 +- 光照数据与方块数据分离,存为 4-bit 数组。 + +#### 4.3.8 视距与 LOD + +- 动态视距:根据帧率调整,例如 6~16 区块。 +- 远处区块可用低精度网格,减少顶点数。 +- 区块网格只在方块变化时重建,不每帧重建。 + +#### 4.3.9 实体与随机刻 + +- 实体按区块激活,超出视距冻结。 +- 随机刻每个 section 每 tick 只选少量方块,避免全扫。 +- 生物 AI、液体流动等使用独立 goroutine 或固定 tick 更新。 + +--- + +## 5. 渲染与网格构建 + +### 5.1 面剔除 + +- 只有相邻为透明方块时才生成该面。 +- 被完全不透明方块包围的面不渲染。 + +### 5.2 透明与半透明 + +- 不透明方块:主渲染 pass。 +- 半透明方块(水、玻璃、树叶):单独 pass,按距离从远到近排序。 +- 树叶用 `cutout` alpha test,避免排序。 + +### 5.3 顶点格式 + +建议: + +```go +type Vertex struct { + X, Y, Z float32 + U, V float32 + R, G, B, A uint8 + SkyLight, BlockLight uint8 +} +``` + +- 使用 VAO/VBO,每个 chunk 一个 mesh。 +- 同材质合并 draw call。 + +### 5.4 纹理图集 + +- 所有方块纹理打包成一张大图。 +- UV 使用图集偏移,避免相邻纹理渗色。 +- 开启 mipmap 与各向异性过滤。 + +### 5.5 动态方块纹理 + +- 水、岩浆、火把等通过 uniform 时间偏移或 texture array 实现动画。 +- 不要在 CPU 每帧重传纹理。 + +### 5.6 视锥剔除 + +- 每个 chunk 有 AABB。 +- 每帧只提交视锥内的 chunk。 +- 使用 `gl.CullFace` 或 CPU 视锥裁剪。 + +--- + +## 6. 方块、物品与方块实体 + +### 6.1 方块注册表 + +- 使用稳定字符串 ID 映射到内部数字 ID。 +- 序列化时保存字符串 ID,避免版本变化。 +- 方块状态用位域或小型 `struct`,不要用大 `map`。 + +```go +type Block struct { + ID uint16 + Name string + Hardness float32 + Resistance float32 + Light uint8 + Transparent bool + Collidable bool + Model string + States map[string][]string +} +``` + +### 6.2 物品与配方 + +- 配方数据驱动:`assets/config/recipes.json`。 +- 支持有序/无序合成、熔炼。酿造(药水台 / `brewing` 配方)**后置**,见 [物品与背包.md](场景/物品与背包.md) §4。 +- 工具属性、耐久、堆叠上限。 + +```json +{ + "type": "crafting_shaped", + "pattern": ["XXX", " X ", " X "], + "key": {"X": "stick"}, + "result": {"item": "torch", "count": 4} +} +``` + +### 6.3 方块实体 + +- 箱子、熔炉等需要额外数据,必须实现: + - `Tick()` + - `Serialize()` + - `Deserialize()` +- 方块实体随区块保存/加载。 + +### 6.4 方块更新 + +- 方块改变时通知相邻方块。 +- 使用队列延迟更新,防止无限递归。 +- 红石、活塞、水等方块更新必须有最大迭代限制。 + +--- + +## 7. 物理与碰撞 + +### 7.1 玩家碰撞 + +- 玩家 AABB:宽 `0.6`,高 `1.8`,眼睛高度 `1.62`。 +- 潜行高度 `1.5`,宽度不变。 +- 逐轴移动并检查碰撞,避免穿墙。 + +### 7.2 非立方体碰撞 + +- 栅栏、台阶、雪层、楼梯等需要自定义碰撞形状。 +- 形状可用多个 AABB 列表。 + +### 7.3 重力与运动 + +- 重力加速度、跳跃速度、摔落伤害。 +- 水、岩浆中的浮力与减速。 +- 飞行、鞘翅等特殊移动模式。 + +--- + +## 8. 光照系统 + +### 8.1 天空光 + +- 从最高方块向下传播,每层减 1。 +- 部分透明方块(如树叶、水)会有额外衰减。 + +### 8.2 方块光 + +- 光源亮度 0~15。 +- BFS 传播,每经过一个方块减 1。 +- 跨区块更新需要加载相邻区块。 + +### 8.3 顶点光照 + +- 每个面四个顶点的光照分别计算。 +- 支持平滑光照,避免硬边。 + +--- + +## 9. 存档与序列化 + +### 9.1 区块数据 + +- 建议使用 palette 压缩方块状态。 +- 实体和方块实体使用 JSON/NBT。 +- 存档文件使用 `zstd` 或 `gzip` 压缩。 + +### 9.2 Region 文件 + +- 每个 region 文件包含 `32x32` 个区块。 +- 文件头记录每个区块的偏移和大小。 +- 崩溃恢复:写入临时文件后原子替换。 + +### 9.3 世界元数据 + +`worlds//level.dat`: + +```json +{ + "version": 1, + "seed": 123456789, + "player": { + "x": 0, "y": 64, "z": 0, + "health": 20, + "inventory": [] + }, + "time": 12000, + "weather": "clear", + "game_mode": "survival" +} +``` + +### 9.4 版本迁移 + +- 存档包含 `version` 字段。 +- 启动时检测旧版本,执行迁移并备份。 +- 不要破坏旧存档,先备份再迁移。 + +--- + +## 10. 网络与多人 + +### 10.1 协议设计 + +- 消息类型: + - 握手、登录 + - 区块数据 + - 方块更新 + - 实体移动 + - 聊天 + - 背包同步 + - 音效/粒子 +- 区块数据压缩后发送。 +- 服务器权威:方块破坏、实体 AI、物品由服务器决定。 + +### 10.2 同步优化 + +- 实体位置 20Hz 同步。 +- 方块变化立即广播,但限制每 tick 发送量。 +- 只发送视距内区块,按距离分包。 + +### 10.3 防作弊基础 + +- 移动速度校验。 +- 挖掘速度与距离检查。 +- 物品 ID 合法性校验。 + +--- + +## 11. UI、输入与音频 + +### 11.1 UI + +- 物品栏、合成、熔炉、容器界面。 +- 支持拖拽、Shift 点击快捷移动。 +- 多语言文本必须从语言文件读取。 + +### 11.2 输入 + +- 可配置键位。 +- 鼠标捕获与释放。 +- 窗口焦点暂停游戏。 + +### 11.3 音频 + +- 3D 音效衰减。 +- 限制同时播放的音源数量。 +- 背景音乐与环境音。 + +--- + +## 12. 容易遗漏的功能点清单 + +以下功能在复刻时经常被遗漏,建议在规划阶段就纳入: + +### 12.1 世界与方块 + +- [ ] 半透明方块渲染排序 +- [ ] 水/岩浆流动逻辑、源方块与流方块 +- [ ] 面剔除与相邻方块更新导致的面重建 +- [ ] 光照更新跨区块延迟 +- [ ] 方块随机刻:作物生长、火传播、冰融化 +- [ ] 红石/活塞延迟与方块推动 +- [ ] 方块实体 Tick 与保存 +- [ ] 生物群系过渡避免突变 +- [ ] 洞穴与结构生成避开液体/基岩 +- [ ] 矿物分布按深度与生物群系 +- [ ] 世界边界与出生点保护 +- [ ] 传送门/维度切换 + +### 12.2 实体与玩家 + +- [ ] 玩家潜行、冲刺、游泳、飞行状态 +- [ ] 摔落伤害与饥饿系统 +- [ ] 生物 AI 寻路 +- [ ] 生物生成/消失规则 +- [ ] 实体碰撞推挤 +- [ ] 玩家重生点 +- [ ] 经验与附魔(如果目标支持) + +### 12.3 渲染与资源 + +- [ ] 纹理图集与 mipmap +- [ ] 动态纹理(水、火把) +- [ ] 视锥剔除与 LOD +- [ ] 资源包热重载 +- [ ] 多语言支持 +- [ ] 粒子系统 +- [ ] 天空盒、云、太阳/月亮渲染 + +### 12.4 存档与配置 + +- [ ] 版本迁移与备份 +- [ ] 崩溃自动保存与恢复 +- [ ] 区块卸载时保存实体 +- [ ] 配置文件格式校验 +- [ ] 日志分级与归档 + +### 12.5 性能与稳定性 + +- [ ] 浮点精度问题:世界原点重定位 +- [ ] 内存池与 `sync.Pool` +- [ ] 区块加载优先级队列 +- [ ] 每帧时间预算 +- [ ] Race detector 检查并发 +- [ ] pprof 性能分析 +- [ ] 防止 goroutine 泄漏 + +### 12.6 多人 + +- [ ] 玩家背包同步 +- [ ] 区块数据分包 +- [ ] 聊天与命令系统 +- [ ] 服务器发现与心跳 +- [ ] 防作弊基础校验 + +--- + +## 13. 开发流程与测试 + +### 13.1 单元测试 + +- 噪声函数确定性 +- 坐标转换(负数) +- 区块序列化/反序列化 +- 光照传播 +- 物品背包逻辑 + +### 13.2 集成测试 + +- 生成 100x100 区块世界,验证无空洞 +- 加载/保存循环测试 +- Mod 加载沙箱测试 + +### 13.3 性能测试 + +- 使用 `go test -bench` 测试区块生成、网格构建 +- 使用 `pprof` 分析 CPU/内存 +- 使用 `go run -race` 检查并发问题 + +--- + +## 14. 配置示例 + +### 14.1 `config/server.yaml` + +```yaml +server: + port: 25565 + max_players: 20 + view_distance: 10 + tick_rate: 20 +world: + seed: 123456789 + name: "NewWorld" + generator: + biome_scale: 0.005 + height_scale: 0.01 + cave_density: 0.1 +mods: + enabled: true + directory: "mods" + allow_scripts: true + script_timeout_ms: 5 +``` + +### 14.2 `config/client.yaml` + +```yaml +client: + window_width: 1280 + window_height: 720 + window_title: "年糕历险记" # 窗口与安装包统一中文名 + vsync: true + fov: 70 + render_distance: 12 + language: "zh_cn" # en_us / zh_cn + graphics: + smooth_lighting: true + mipmap: true + anisotropic: 4 + audio: + volume: 1.0 + music: true +``` + +--- + +## 15. 总结 + +本规范覆盖了 Go 复刻类 Minecraft 游戏的核心结构与容易遗漏的细节。 +关键原则: + +1. **资源与代码分离**:素材放 `assets/`,Mod 放 `mods/`,存档放 `worlds/`。 +2. **Mod 不依赖 Go plugin**:使用 Lua/JS 脚本 + 数据驱动。 +3. **无限世界必须异步生成**:worker pool + 时间预算 + 优先级队列。 +4. **光照、存档、渲染都必须是流式**,避免主线程卡顿。 +5. **从第一天就设计版本迁移与崩溃恢复**。 + 这份规范可以直接作为团队开发基线或个人项目检查表使用。 \ No newline at end of file diff --git a/文档/路线图.md b/文档/路线图.md index 9c56fc2f..57ed3cd7 100644 --- a/文档/路线图.md +++ b/文档/路线图.md @@ -10,104 +10,112 @@ | M0 骨架 | 目录、模块接口、配置、日志、主循环 | 已完成 | | M1 世界 | 区块、确定性生成、光照、存档 | 已完成 | | M2 渲染 | 网格、图集、透明排序、视锥剔除 | 已完成 | -| M3 游戏性 | 方块交互、物理、背包、配方、实体 | 已完成(破坏裂纹叠加、手持方块、原版掉落与实体攻击已补齐;背包/熔炉 GUI 后置) | -| M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(实体 AI/僵尸/村民/工作台数据与 3×3 合成界面/客户端实体渲染/动物攻击与掉落/村庄村民生成已完成;村民交易与动物繁殖后置) | -| M4 多人 | 网络协议、同步、防作弊 | 进行中(协议/服务器/客户端网络层/区块流/方块广播/实体位置广播/心跳已完成;插值渲染与完整防作弊后置) | +| M3 游戏性 | 方块交互、物理、背包、配方、实体 | 已完成(含工具耐久、饥饿消耗、死亡界面、随机刻草蔓延、水流、红石尘/拉杆;活塞后置) | +| M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(工作台 3×3、村庄刷村民、僵尸 AI/燃烧、动物 `Breed`/`LoadTrades` 数据层已有;**会话未接**:交易窗、喂食繁殖、建造 Tick、围攻) | +| M4 多人 | 网络协议、同步、防作弊 | 进行中(协议/服务器/客户端网络层/区块流/方块广播/实体位置广播/心跳已完成;插值渲染、背包权威、聊天、完整防作弊后置) | | M5 Mod | 加载器、Lua API、沙箱、资源合并 | 已完成 | -| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频接入/UI 精灵与主菜单/设置界面(灵敏度/音量/种子/按键教程)/准星方块名/小地图/物品栏(E 键)与 Q 丢弃/光影包/存档/安装脚本/合成台界面已完成;熔炉界面、性能池化与安装包构建后置) | +| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频/HUD/选档/熔炉箱子创造栏/拖拽均分双击/配方书/NSIS 安装包已落地;**未做**:死亡界面、经验条、设置分页、性能池化全面化、崩溃恢复) | + +## 下一轮候选 + +2026-08-19 已落地:生存闭环、生态接会话、世界规则(随机刻草/水流/红石尘+拉杆、视距卸载、SavePool)。 + +下一条建议:**多人**(先服务器权威方块,再聊天/背包同步/插值)。 + +维持后置:酿造、双箱、附魔、item display JSON、群系逐顶点染色、活塞、作物/火/冰随机刻、完整防作弊、实体入档。 ## M0 骨架 -- [ ] 目录结构与 `go.mod`(设计.md §1.1) -- [ ] config 解析与校验(client / server yaml,设计.md §14) -- [ ] 日志分级与归档 -- [ ] 主循环(tick 20Hz + render,架构.md §3.2) -- [ ] 资源管理器骨架(assets) +- [x] 目录结构与 `go.mod`(设计.md §1.1) +- [x] config 解析与校验(client / server yaml,设计.md §14) +- [x] 日志分级与归档 +- [x] 主循环(tick 20Hz + render,架构.md §3.2) +- [x] 资源管理器骨架(assets) **验收**:`go run ./cmd/server` 可启动并优雅退出;配置解析有单测覆盖。 ## M1 世界 -- [ ] 区块 / section 数据结构(palette,场景/区块管理.md §2 + 存档格式.md §4) -- [ ] 确定性噪声生成管线(高度图 → 生物群系 → 洞穴 → 矿物 → 结构,场景/世界生成.md) -- [ ] 光照(天空光 / 方块光,场景/光照.md §3–§4) -- [ ] 区块状态机 + worker pool + 优先级队列(场景/区块管理.md §3–§4) -- [ ] 存档(level.dat、region、palette、迁移框架,场景/存档与持久化.md + 存档格式.md) +- [x] 区块 / section 数据结构(palette,场景/区块管理.md §2 + 存档格式.md §4) +- [x] 确定性噪声生成管线(高度图 → 生物群系 → 洞穴 → 矿物 → 结构,场景/世界生成.md) +- [x] 光照(天空光 / 方块光,场景/光照.md §3–§4) +- [x] 区块状态机 + worker pool + 优先级队列(场景/区块管理.md §3–§4) +- [x] 存档(level.dat、region、palette,场景/存档与持久化.md + 存档格式.md;迁移框架仍薄) **验收**:任意种子生成无空洞;保存 / 重载数据一致;`-race` 全绿。 对应 设计.md §12.1 世界与方块清单。 ## M2 渲染 -- [ ] GL 线程亲和与专用渲染 goroutine(场景/渲染.md §2) -- [ ] 纹理图集与 mipmap(场景/渲染.md §4) -- [ ] 网格构建:face culling → greedy meshing(场景/渲染.md §3) -- [ ] 透明 / 半透明排序(场景/渲染.md §6) +- [x] GL 线程亲和与专用渲染 goroutine(场景/渲染.md §2) +- [x] 纹理图集与 mipmap(场景/渲染.md §4) +- [ ] 网格构建:face culling 已有;greedy meshing 未做(场景/渲染.md §3) +- [ ] 透明 / 半透明排序(雏形有,距离排序未齐,场景/渲染.md §6) - [ ] 视锥剔除与 LOD(场景/渲染.md §7) - [ ] 动态纹理(水、火把,场景/渲染.md §8) -- [ ] 默认光影系统 + 光影包加载与回退(场景/渲染.md §9、设计.md §1.3) +- [x] 默认光影系统 + 光影包加载与回退(场景/渲染.md §9、设计.md §1.3) **验收**:视距 12 在常规机器 ≥60fps;对应 设计.md §12.3 渲染清单。 ## M3 游戏性 -- [ ] 玩家 AABB 碰撞与移动(场景/物理与碰撞.md) -- [ ] 方块交互:DDA 拾取、破坏进度、耐久、放置(场景/方块交互与动画.md §2–§6) -- [ ] 破坏动画:crack 阶段叠加(场景/方块交互与动画.md §4) -- [ ] 方块状态模型:ID + meta + state(场景/方块交互与动画.md §8) -- [ ] 背包、配方、熔炼(设计.md §6.2) -- [ ] 方块实体:箱子 / 熔炉(场景/物品与背包.md §6) -- [ ] 实体基础:生命、受伤、掉落(场景/实体系统.md §2、§5) -- [ ] 随机刻:作物、水、草蔓延(场景/世界生态.md §4) -- [ ] 火把光源:放置触发 BFS、移除触发 relight(场景/光照.md §4–§5) -- [ ] 红石 / 活塞(延迟与推动上限,场景/红石与方块更新.md §3–§4) -- [ ] 物品栏 / 合成台 / 熔炉界面(场景/UI与输入.md §3、场景/物品与背包.md §4、场景/UI还原.md §2–§3) +- [x] 玩家 AABB 碰撞与移动(场景/物理与碰撞.md) +- [x] 方块交互:DDA 拾取、破坏进度、放置、工具耐久消耗(场景/方块交互与动画.md §2–§6) +- [x] 破坏动画:crack 阶段叠加(场景/方块交互与动画.md §4) +- [x] 方块状态模型:ID + meta + state(场景/方块交互与动画.md §8) +- [x] 背包、配方、熔炼 GUI(设计.md §6.2;酿造后置) +- [x] 方块实体:箱子 / 熔炉(场景/物品与背包.md §6;写入 level.dat) +- [x] 实体基础:生命、受伤、掉落(场景/实体系统.md §2、§5;实体本身不入档) +- [x] 随机刻:每 section 抽 3 格,本轮草蔓延(作物/火/冰后置) +- [x] 火把光源:放置触发 BFS、移除触发 relight(场景/光照.md §4–§5) +- [x] 红石:Queue 挂 Tick + 尘/拉杆(活塞后置,场景/红石与方块更新.md §3–§4) +- [x] 物品栏 / 合成台 / 熔炉 / 箱子界面(场景/UI与输入.md §3、场景/物品与背包.md §4、场景/UI还原.md §2–§3) **验收**:可完成生存循环(采集 → 合成 → 建造);对应 设计.md §12.2 实体与玩家清单。 ## M3.5 生态与文明 -- [ ] 工作台 3×3 合成与配方索引(场景/工作台与合成表.md) -- [ ] 挖矿闭环:矿物分布、工具门槛、掉落/烧炼(场景/挖矿与矿物.md) -- [ ] 动物体系:行为 AI、繁殖、剪羊毛/牛奶、数量控制(场景/动物体系.md) -- [ ] 村庄生成 + 职业村民日程(场景/村民系统.md §2–§4) -- [ ] **村民建造房子**(程序化蓝图 + 建造队列,场景/村民系统.md §6) -- [ ] 村民交易(简单交易表,场景/村民系统.md §7) -- [ ] 僵尸敌对生物:AI、攻击、白天燃烧(场景/僵尸与敌对生物.md §2–§4) +- [x] 工作台 3×3 合成与配方索引(场景/工作台与合成表.md) +- [x] 挖矿闭环:矿物分布、工具门槛、掉落/烧炼(场景/挖矿与矿物.md) +- [x] 动物体系:`TickAnimal` 已挂会话;喂食繁殖 / 剪羊毛 / 牛奶已接(驯服骑乘后置) +- [ ] 村庄生成已有;职业村民日程未接(场景/村民系统.md §2–§4) +- [x] **村民建造房子**:建造者生成填蓝图,`BuildTick` 写入空气格(场景/村民系统.md §6) +- [x] 村民交易:右键开 `screen=12`,`ExecuteTrade` 校验背包(场景/村民系统.md §7) +- [x] 僵尸敌对生物:AI、攻击、白天燃烧(场景/僵尸与敌对生物.md §2–§4) - [ ] 僵尸村庄围攻联动(场景/僵尸与敌对生物.md §7) **验收**:村庄里村民会自己盖出完整房子(自动化测试 20 游戏日);动物可繁殖不爆炸;夜间僵尸围攻村庄且天亮停止。 ## M4 多人 -- [ ] 帧 / 消息编解码(网络协议.md §1–3) -- [ ] 握手 / 登录、区块流(网络协议.md §4–5) -- [ ] 实体 20Hz 同步与插值(场景/多人同步.md §3) -- [ ] 方块 / 背包同步与服务器权威校验(场景/多人同步.md §5) -- [ ] 心跳、超时、重连(网络协议.md §7) +- [x] 帧 / 消息编解码(网络协议.md §1–3) +- [x] 握手 / 登录、区块流(网络协议.md §4–5) +- [ ] 实体 20Hz 同步与插值(位置广播有,客户端插值未做,场景/多人同步.md §3) +- [ ] 方块 / 背包同步与服务器权威校验(仍本地破坏/放置,场景/多人同步.md §5) +- [x] 心跳、超时、重连(网络协议.md §7) - [ ] 防作弊细化:移动 / 挖掘 / 包频率(场景/多人同步.md §8) **验收**:双客户端联机,方块变更互见;对应 设计.md §12.6 多人清单。 ## M5 Mod -- [ ] 加载器:扫描、依赖拓扑、API 校验(设计.md §3.3) -- [ ] gopher-lua 集成与沙箱(设计.md §3.5) -- [ ] 资源命名空间合并(设计.md §1.2) -- [ ] 数据文件注册:方块 / 物品 / 配方(Mod开发指南.md §5) -- [ ] 热更新:注册表 generation 版本化、原子切换与回滚(场景/热更新.md) -- [ ] 热重载(开发模式,Mod开发指南.md §7、场景/热更新.md §4) +- [x] 加载器:扫描、依赖拓扑、API 校验(设计.md §3.3) +- [x] gopher-lua 集成与沙箱(设计.md §3.5) +- [x] 资源命名空间合并(设计.md §1.2) +- [x] 数据文件注册:方块 / 物品 / 配方(Mod开发指南.md §5) +- [x] 热更新:注册表 generation 版本化、原子切换与回滚(场景/热更新.md;引擎 Go 代码热更新后置) +- [ ] 热重载 Watcher 未挂启动(开发模式,Mod开发指南.md §7、场景/热更新.md §4) **验收**:示例 Mod 注册的新方块 / 物品 / 配方在游戏中生效。 ## M6 打磨 -- [ ] 音频:3D 衰减、声源上限、音乐(场景/音频.md) -- [ ] UI:拖拽、Shift 点击、多语言、**一比一还原原版界面**(场景/UI与输入.md + 场景/UI还原.md) -- [ ] 生物 AI 完善:状态机与限频寻路(场景/实体系统.md §3) +- [x] 音频:3D 衰减、声源上限、音乐(场景/音频.md) +- [ ] UI:拖拽 / Shift / 均分 / 双击 / 配方书已有;死亡界面、经验条、设置分页、聊天、`§` 色码未做(场景/UI与输入.md + 场景/UI还原.md) +- [x] 生物 AI 完善:状态机与限频寻路(场景/实体系统.md §3) - [ ] 性能:pprof、内存池、LOD(设计.md §4.3.6、§4.3.8、场景/性能与内存.md) -- [ ] 崩溃恢复与自动保存(场景/存档与持久化.md §5) -- [ ] 打包发布(构建与发布.md §6 清单) +- [ ] 崩溃恢复(自动恢复路径仍后置);定时/卸载走 SavePool,关服 Wait +- [x] 打包发布(构建与发布.md §6:dev 脚本 + NSIS;完整发布清单仍待勾) **验收**:发布清单全绿;文档索引完整;对应 设计.md §12.4、§12.5 清单。 @@ -135,3 +143,4 @@ | (客户端打磨) | 客户端渲染修复与还原:相机视图矩阵(俯仰符号)、植被染色(草/树叶)、原版 HUD 精灵(心/鸡腿/热键栏/准星)、破坏裂纹 3D 叠加、第一人称手持方块、实体广告牌渲染(僵尸/村民/动物)、主菜单字号修复;M3 破坏动画与实体渲染相关条目更新 | — | | (玩法与设置) | 方格垂直面剔除修复(五面绕序反向致立面透明)、鼠标左右反转修复、音频接入(破坏/放置/攻击)、原版掉落(草→泥土/石→圆石/树叶无掉落)、动物攻击与死亡掉落、村庄村民生成、世界种子与鼠标灵敏度配置、设置界面(滑条/种子/按键教程)、准星方块名展示、截图归入根目录 jietu/ | — | | (稳定性与观感) | 卸载区块实体冻结(下坠越界崩溃修复)+ 光查询越界防御;mesh 面光照改取邻格(悬崖/矿洞立面不再全黑);掉落 1 秒拾取延迟;打击感(命中音/受击白闪/挥动动画);水下蓝色叠加;空手肤色拳头;血条左/饥饿右布局 | — | +| 2026-08-19 | 对照代码同步过期表述:M3/M6 不再写「背包/熔炉 GUI 后置」;M3.5 繁殖改为「数据层有、会话未接」;勾选已落地条目;新增「下一轮候选」四条产品线 | — |