修复了一些bug
This commit is contained in:
15
README.md
15
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`
|
||||
|
||||
**手动步骤**(不依赖一键脚本):
|
||||
|
||||
@@ -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" }] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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": "打嗝"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("服务器已退出")
|
||||
}
|
||||
|
||||
|
||||
2
go.mod
2
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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("开启的拉杆应给相邻红石尘充能")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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 调试输出。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("原点区块应已卸载")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<ns>/...`,自动视为素材包,无需转换。
|
||||
2. **扩展格式**(可选):`manifest.json`(`"type": "resource"`,可声明 `priority`),包结构同 Mod 格式。
|
||||
|
||||
## 规则
|
||||
|
||||
- 优先级:`功能 Mod 资源 > 素材包 > 光影包(仅渲染阶段) > 默认嵌入素材`。
|
||||
- 同类型冲突:`priority` 大者优先,相同则后加载者覆盖并告警。
|
||||
- 开发模式支持热重载(改图 → 图集重建 → mesh 重传,见 场景/热更新.md)。
|
||||
@@ -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/(可执行文件目录解析:双击/快捷方式运行无需依赖工作目录)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`;暂停保存/不保存分流 | — |
|
||||
|
||||
@@ -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`;暂停拆成保存存档 / 保存并退出 / 退出游戏(不保存) | — |
|
||||
|
||||
@@ -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 流,岩浆静态 | — |
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
- 行为 AI 复用 实体系统.md §3 的状态机:`idle → wander → follow → flee → eat`。
|
||||
- **跟随**:手持食物(小麦/胡萝卜)→ 动物跟随玩家(简单寻路,限频)。
|
||||
- **音效**(音频.md §6.2):闲置 `entity.<species>.ambient`(`ambientSoundTime`,`rand(1000) < time` 后重置为 −80);受击 `hurt`、致死 `death`;走路播脚下 `block.<group>.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 / 喂食繁殖 / 剪羊 / 牛奶 | — |
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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 不关池 | — |
|
||||
|
||||
@@ -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;背包掉落后置 | — |
|
||||
|
||||
@@ -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 落地) | — |
|
||||
|
||||
@@ -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 无附魔掉落表(工具门槛、数量区间、沙砾燧石、树叶概率);掉落物必须注册为物品 | — |
|
||||
|
||||
@@ -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.<group>.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 | — |
|
||||
|
||||
@@ -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 接入会话 | — |
|
||||
|
||||
43
文档/场景/渲染.md
43
文档/场景/渲染.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:第一人称手臂盒模型、物品挤出薄片、绕肩挥动 | — |
|
||||
|
||||
@@ -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 / 归零销毁;创造不扣 | — |
|
||||
|
||||
@@ -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.<group>.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 | — |
|
||||
|
||||
@@ -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 + 红石尘 + 拉杆;活塞/火把红石/中继器后置 | — |
|
||||
|
||||
55
文档/场景/音频.md
55
文档/场景/音频.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/<pack>/assets/minecraft/sounds.json` + `assets/minecraft/sounds/<name>.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.<group>.break` |
|
||||
| 放置 | `block.<group>.place` |
|
||||
| 挖掘敲击(每 4 tick,含第 0 tick) | `block.<group>.hit` |
|
||||
| 脚步(`walkDist += 水平位移*0.6`,越过下一整数) | `block.<group>.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.<species>.ambient`(`ambientSoundTime`,间隔 80) |
|
||||
|
||||
动物走路播脚下 `block.<group>.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 | — |
|
||||
|
||||
15
文档/存档格式.md
15
文档/存档格式.md
@@ -25,7 +25,8 @@ worlds/<world>/
|
||||
| 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` 不落盘 | — |
|
||||
|
||||
21
文档/构建与发布.md
21
文档/构建与发布.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) | — |
|
||||
|
||||
26
文档/索引.md
26
文档/索引.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) | — |
|
||||
|
||||
115
文档/路线图.md
115
文档/路线图.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 繁殖改为「数据层有、会话未接」;勾选已落地条目;新增「下一轮候选」四条产品线 | — |
|
||||
|
||||
Reference in New Issue
Block a user