修复了一些bug

This commit is contained in:
NianGao Dev
2026-09-19 14:21:08 +08:00
parent db81873409
commit 6c23eb4c62
73 changed files with 7354 additions and 2205 deletions

View File

@@ -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`
**手动步骤**(不依赖一键脚本):

View File

@@ -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" }] }
}
}

View File

@@ -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" }
}
}

View File

@@ -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 } },

View File

@@ -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

View File

@@ -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 返回动物方块模型variant0 牛 / 1 猪 / 2 羊 / 3 鸡)。
// 贴图子矩形按原版皮肤布局:头部在纹理左侧、身体在右侧、腿在下部。
// 图集 tile 内纹理被等比拉伸成 64×6464×32 原图纵向拉长一倍,矩形已按拉伸后比例折算)。
func animalModel(variant int) render.AnimalModel {
func animalSkinKeys(variant int) []string {
switch variant {
case 0: // 牛:纹理 64×64内容在上半v 00.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×32tile 内 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"}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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_tickMod开发指南.md §4.3
if s.tickCnt%20 == 0 {
for _, m := range s.luaMods {
_ = m.DispatchEvent("tick")
}
}
// 定时存档30s存档与持久化.md §2
// 定时存档30s 入队(存档与持久化.md §4tick 内不 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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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.81.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)
}
}

View File

@@ -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否则 01
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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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 剪羊毛:未剪过的羊掉 13 白羊毛。
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

View File

@@ -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 {

View File

@@ -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.3atan2(vx,vz)+π)。
func faceVelYaw(vx, vz float64) float64 {
return math.Atan2(vx, vz) + math.Pi
}

View File

@@ -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

View File

@@ -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 次 hit0 与 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("开启的拉杆应给相邻红石尘充能")
}
}

View File

@@ -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.327 主区 + 9 热键栏)。
// 背包槽位常量UI还原.md §3.3、物品与背包.md §3)。
const (
HotbarSize = 9 // 热键栏槽数
MainSize = 27 // 主物品区槽数
TotalSize = HotbarSize + MainSize
HotbarSize = 9
MainSize = 27
StorageSize = HotbarSize + MainSize // 035Add/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 槽08 热键栏、935 主区、3639 盔甲、40 副手
Selected int // 热键栏选中索引 08
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 把物品栈放入存储区035先合并再空槽返回剩余。
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 从存储区 035 聚堆到 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 {

View File

@@ -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)
}
}

View File

@@ -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 ""
}

View File

@@ -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 调试输出。

View File

@@ -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
}
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)(脚底)以 yaw0=朝 +z绘制方块动物模型。
// DrawAnimalModel 在 (x,y,z)(脚底)以 yaw 绘制盒模型(脸在 ZEntity.Yaw 已 +π)。
// tile 为实体图集内动物纹理的归一化矩形;部件 UV 相对 tile 定义。
// flash=true 受击白闪uTint 提亮)。
func (r *Renderer) DrawAnimalModel(x, y, z, yaw float32, model AnimalModel, tile [4]float32, flash bool) {

View File

@@ -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)

View File

@@ -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指尖朝 -Y4×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)
}
}

View File

@@ -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() }

View File

@@ -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 写入区块:重写整个 region8KB 头 + 紧密扇区),再原子替换
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
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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.png256×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 命中grid08 / result / main026 / hot08 / 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 绘制工作台界面(调用方已 Beginmx/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×166UI还原.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
}

View File

@@ -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 热键栏切槽弹出缩回(约 150ms1.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 §43D 裂纹由渲染层叠加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)
}

View File

@@ -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.png256×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 命中main026 / hot08 / armor03 / offhand / craft03 / 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×2res 为输出)。
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)
}

View File

@@ -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})
}

View File

@@ -1,118 +1,172 @@
//go:build gl
// 界面屏幕主菜单与暂停菜单UI还原.md §2布局/控件/版本角标复刻原版)。
// 界面屏幕主菜单与暂停菜单UI还原.md §2427×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 §23D 全景背景 + 缩放控件)。
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})
}
}

View File

@@ -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] }

View File

@@ -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

View File

@@ -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("原点区块应已卸载")
}
}

View File

@@ -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.mcmetapack_format 34/46
含 atlases / blockstates1198/ models3928/ textures / font / lang(en_us) 完整结构。
客户端/服务器启动时自动加载,作为默认材质来源(初版材质基线)。
## 支持两种格式(自动识别)
1. **原生格式**(推荐,用户下载的包直接放):根目录 `pack.mcmeta` + `assets/<ns>/...`,自动视为素材包,无需转换。
2. **扩展格式**(可选):`manifest.json``"type": "resource"`,可声明 `priority`),包结构同 Mod 格式。
## 规则
- 优先级:`功能 Mod 资源 > 素材包 > 光影包(仅渲染阶段) > 默认嵌入素材`
- 同类型冲突:`priority` 大者优先,相同则后加载者覆盖并告警。
- 开发模式支持热重载(改图 → 图集重建 → mesh 重传,见 场景/热更新.md

View File

@@ -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/(可执行文件目录解析:双击/快捷方式运行无需依赖工作目录)

View File

@@ -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/Releasetags 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

View File

@@ -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 |
| 滚轮 | 热键栏切换;数字键 19 直选 |
| 长按 | 破坏按住累积(方块交互与动画.md §3放置长按连续放置带间隔 |
| 滚轮 | 热键栏切换,对齐原版 Java**上滚 = 槽位 1**,下滚 = +1;数字键 19 直选 |
| 长按 | 破坏按住累积,挖完一块后继续对准星方块(方块交互与动画.md §3放置长按连续放置带间隔;挥击音仅按下边沿 |
| 窗口 | 可最大化;**F11** / **Alt+Enter** 切换全屏(渲染.md §5.4framebuffer 变化后更新 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 | §3Loading `screen=13`;暂停保存/不保存分流 | — |

View File

@@ -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 内第二次左键:同类聚到光标直到堆叠上限 |
| 滚轮 / 数字键 19 | 切换热键栏槽 |
| 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`;暂停拆成保存存档 / 保存并退出 / 退出游戏(不保存) | — |

View File

@@ -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 源 / 17 流,岩浆静态 | — |

View File

@@ -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 | §2ambient / hurt / death 与动物脚步(方块 step | — |
| 2026-08-18 | §2§3`Animal.Species`;繁殖必须同种 | — |
| 2026-08-19 | §3会话接入 TickAnimal / 喂食繁殖 / 剪羊 / 牛奶 | — |

View File

@@ -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视距外 unloadingSaveDirty 整 chunk 入 SavePool | — |

View File

@@ -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 写入:重写整个 region8KB 头 + 打包扇区),禁止「新头 + 整份旧文件」拼接(会错位,载入报魔数错误)。
- `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 atomicReplaceregion 整文件重写;坏块跳过 | — |
| 2026-08-17 | §5ReadChunk 长度越界/CRC 失败不再冒泡为 LoadWorld 失败 | — |
| 2026-08-18 | §5.1ListWorlds / DeleteWorld选档 UI | — |
| 2026-08-19 | §4SavePool 落地;本轮整 chunk 入队;关服 Wait30s 不在 tick fsync | — |
| 2026-08-19 | §2/§4暂停手动保存退出不保存按磁盘重载Wait 不关池 | — |

View File

@@ -59,8 +59,12 @@ spawn → tick激活区块内→ damage → death → 掉落物 → despaw
## 6. 玩家实体状态
- 生命 / 饥饿(饱和、消耗、恢复)/ 经验(可选)。
- 死亡:掉落背包(可选保留)→ 重生点复活。
- 生命 / 饥饿(饱和、消耗、恢复)/ 经验(可选,本轮不做)。
- 饥饿规则(生存模式;创造不消耗):
- `Hunger` 020`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背包掉落后置 | — |

View File

@@ -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、关闭退回材料CloseCraftingUI 规格按 UI还原.md §3.4176×166 窗口 + 网格 (30,17) + 输出 (124,35)Shift 批量合成与配方书后置 | — |
| (合成界面) | 客户端工作台 3×3 界面落地右键命中工作台打开TryOpenCrafting、网格槽位与手持栈交换/合并CraftingSwap、输出格取产物消耗材料CraftingTakeResult、关闭退回材料CloseCraftingUI 规格按 UI还原.md §3.4176×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 落地) | — |

View File

@@ -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_sapling0.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 ×45 | 铁镐+ |
| 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 | §41.20.5 无附魔掉落表(工具门槛、数量区间、沙砾燧石、树叶概率);掉落物必须注册为物品 | — |

View File

@@ -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 | — |

View File

@@ -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§7BuildTick / 交易窗 screen=12 接入会话 | — |

View File

@@ -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 = 进度×1009满亮无光照`gl.PolygonOffset` 防 z-fighting。裂纹图集独立打包上传10 阶段)。
- **第一人称手持**
- 空手:肤色拳(白羊毛纹理 × 肤色 tint
- 手持**可放置方块**:视图空间固定位置/尺度的单位立方体(六面 UV 取自选中槽放置方块的 top/down/side
- 手持**放置物品**(工具/食物等):实体图集内物品贴图的**小四边形**轻微倾斜、双面、alpha test原版手持角度近似随挥动动画平移
- 顶点直接以视图坐标构造,绘制时只乘投影矩阵
- **第一人称手持**(原版右手近似,不解析 `display.firstperson_righthand`
- **手臂始终画出**:人形右臂盒 4×12×4UV 取玩家皮肤(`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:第一人称手臂盒模型、物品挤出薄片、绕肩挥动 | — |

View File

@@ -24,16 +24,25 @@ type ItemStack struct {
## 3. 背包模型与规则表
- 槽位36 背包 + 9 热键栏 + 盔甲(可选)+ 副手(可选)。
- 槽位布局(存档 `slots` 按下标顺序,旧档仅 36 格仍可载入):
- `08` 热键栏,`935` 主物品区(共 36 格存储,`Add`/`Remove` 只写这里);
- `3639` 盔甲(头盔 / 胸甲 / 护腿 / 靴子);
- `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拖拽均分/双击;箱子放置与 GUIBE 写入 level.dat | — |
| 2026-08-19 | §4`brewing` 明确标**后置**(无酿造台/GUI/药水配方),避免与现范围缺口混淆 | — |
| 2026-08-19 | §5破坏 -1 / 攻击 -2 / 归零销毁;创造不扣 | — |

View File

@@ -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§4InWater/EyeInWater/Swimming 拆分、氧气 15s、冲刺游泳姿态、着水摔落减免 | — |
| 2026-08-18 | §4.1:脚步 walkDist×0.6 跨整数 | — |
| 2026-08-18 | §7fallDist 与 small_fall / big_fall | — |

View File

@@ -36,11 +36,11 @@ type BlockUpdate struct {
| 组件 | 行为 |
| --- | --- |
| 红石线 | 接收相邻信号 max-1 传递;连接规则(十字/上下) |
| 火把红石 | 15 级信号源;被充能则熄灭(反相器) |
| 拉杆/按钮 | 开关 → 充能相邻方块 |
| 中继器(后期) | 信号增强 + 14 tick 延迟 |
| 比较器(后期) | 比较/减法,读取容器内容 |
| 红石线 | 本轮:`redstone_dust``SpreadWire` 衰减,功率写入 meta |
| 拉杆 | 本轮:右键切换,开=15 级源 |
| 火把红石 | 后置 |
| 中继器 / 比较器 | 后期 |
| 活塞 | **后置**§4 |
- 充能规则(简化先做「弱充能」):方块被红石线/火把相邻即激活,激活方块能驱动相邻机械(门、活塞、灯)。
- **强充能 vs 弱充能**(后期精确化):写在 ADR 或本文件附录,避免实现一半返工。
@@ -78,3 +78,4 @@ type BlockUpdate struct {
| 日期 | 变更 | 作者 |
| --- | --- | --- |
| (初始建立) | 初版:更新模型、红石信号、活塞、持久化边界、踩坑清单 | — |
| 2026-08-19 | 本轮接入 Queue + 红石尘 + 拉杆;活塞/火把红石/中继器后置 | — |

View File

@@ -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.81.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 | — |

View File

@@ -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` 下标08 热键栏、935 主区、3639 盔甲、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/§6player 快照字段Windows 原子替换region 整文件重写 | — |
| 2026-08-17 | §5损坏记录视为未保存LoadWorld 不因单块越界/CRC 失败而整档失败 | — |
| 2026-08-18 | §4.4:实体 data 预告动物 `species` | — |
| 2026-08-18 | §2player.slots 扩至盔甲/副手3640旧档前 36 格兼容 | — |
| 2026-08-18 | §2`block_entities``player.unlocked_recipes``game_mode` 创建/读档生效 | — |
| 2026-08-19 | §2player 可选 `saturation``exhaustion` 不落盘 | — |

View File

@@ -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/
| (安装包) | 新增 §8Windows 安装包「年糕历险记」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 | — |

View File

@@ -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.41.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随机刻仅草蔓延水流 07红石尘+拉杆(世界生态.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 | — |

File diff suppressed because it is too large Load Diff

View File

@@ -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 §13
- [ ] 握手 / 登录、区块流(网络协议.md §45
- [ ] 实体 20Hz 同步与插值(场景/多人同步.md §3
- [ ] 方块 / 背包同步与服务器权威校验(场景/多人同步.md §5
- [ ] 心跳、超时、重连(网络协议.md §7
- [x] 帧 / 消息编解码(网络协议.md §13
- [x] 握手 / 登录、区块流(网络协议.md §45
- [ ] 实体 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 §6dev 脚本 + NSIS完整发布清单仍待勾
**验收**:发布清单全绿;文档索引完整;对应 设计.md §12.4、§12.5 清单。
@@ -135,3 +143,4 @@
| (客户端打磨) | 客户端渲染修复与还原:相机视图矩阵(俯仰符号)、植被染色(草/树叶)、原版 HUD 精灵(心/鸡腿/热键栏/准星)、破坏裂纹 3D 叠加、第一人称手持方块、实体广告牌渲染(僵尸/村民/动物、主菜单字号修复M3 破坏动画与实体渲染相关条目更新 | — |
| (玩法与设置) | 方格垂直面剔除修复(五面绕序反向致立面透明)、鼠标左右反转修复、音频接入(破坏/放置/攻击)、原版掉落(草→泥土/石→圆石/树叶无掉落)、动物攻击与死亡掉落、村庄村民生成、世界种子与鼠标灵敏度配置、设置界面(滑条/种子/按键教程)、准星方块名展示、截图归入根目录 jietu/ | — |
| (稳定性与观感) | 卸载区块实体冻结(下坠越界崩溃修复)+ 光查询越界防御mesh 面光照改取邻格(悬崖/矿洞立面不再全黑);掉落 1 秒拾取延迟;打击感(命中音/受击白闪/挥动动画);水下蓝色叠加;空手肤色拳头;血条左/饥饿右布局 | — |
| 2026-08-19 | 对照代码同步过期表述M3/M6 不再写「背包/熔炉 GUI 后置」M3.5 繁殖改为「数据层有、会话未接」;勾选已落地条目;新增「下一轮候选」四条产品线 | — |