feat(crafting): 工作台 3×3 合成界面——右键开台、槽位交换、输出格合成、关闭退回

- 会话层(工作台与合成表.md §5):TryOpenCrafting 右键命中工作台开台;
  CraftingSwap 网格槽与手持栈交换/合并;CraftingTakeResult 匹配配方
  (有序优先)消耗材料取产物;CloseCrafting 材料退回背包(放不下掉落)
- 客户端屏幕状态 3:gui/container/crafting_table.png 176×166 窗口、
  3×3 网格 (30,17) + 输出格 (124,35) + 热键栏行 (7,141)(UI还原.md §3.4);
  ESC 关闭并回收手持栈;界面打开时释放光标、关闭恢复捕获并重置鼠标基准
- 物品图标双图集:block/* 图标回退 blocks 图集(原版 items 图集不收录
  block 纹理,此前方块物品图标全部缺失)
- 单测:shaped 2×2 木板→工作台、shapeless 原木→木板、木镐多符号、
  关闭退回材料;dev 参数 -crafttest 全链路截图验证
This commit is contained in:
NianGao Dev
2026-08-16 10:23:01 +08:00
parent 8bc64d220a
commit 768a9b8096
10 changed files with 559 additions and 24 deletions

View File

@@ -59,6 +59,7 @@ var (
crackStage = flag.Int("crackstage", -1, "dev 测试截图前把破坏进度视觉设置为该阶段09")
spawnTest = flag.String("spawntest", "", "dev 测试:截图前在视线前方 5 格生成一个实体zombie/animal/villager")
escTest = flag.Bool("esctest", false, "dev 测试:截图前注入一次 ESC验证暂停菜单与光标释放链路")
craftTest = flag.Bool("crafttest", false, "dev 测试:截图前打开工作台并预填 2×2 木板(验证合成界面)")
)
// clientApp 客户端应用把会话与渲染粘合起来engine.Ticker + engine.Renderer
@@ -70,10 +71,12 @@ type clientApp struct {
uiR *ui.Renderer
hud *ui.HUD
// 屏幕状态机0 主菜单 / 1 游戏中 / 2 暂停UI还原.md §2
// 屏幕状态机0 主菜单 / 1 游戏中 / 2 暂停 / 3 工作台UI还原.md §2
screen int
titleScreen *ui.TitleScreen
pauseScreen *ui.PauseScreen
craftScreen *ui.CraftingScreen
held item.Stack // 合成界面手持栈(跟随鼠标)
// 联机(多人同步.md §2无连接时为单机
net *netclient.Client
@@ -106,10 +109,12 @@ type clientApp struct {
frames int // 渲染帧计数(截图时机用)
lastTime time.Time
lookInjected bool // dev 测试:-lookdeg 已注入
crackShot bool // dev 测试:-crackstage 已注入
spawnShot bool // dev 测试:-spawntest 已注入
escShot bool // dev 测试:-esctest 已注入
lookInjected bool // dev 测试:-lookdeg 已注入
crackShot bool // dev 测试:-crackstage 已注入
spawnShot bool // dev 测试:-spawntest 已注入
escShot bool // dev 测试:-esctest 已注入
craftShot bool // dev 测试:-crafttest 已注入
craftClickShot bool // dev 测试:-crafttest 点击输出格已注入
}
// worldMeshJob 待上传网格。
@@ -172,6 +177,27 @@ func (a *clientApp) Tick(dt float64) {
}
a.in.escPressed = false
return
case 3: // 工作台(工作台与合成表.md §5点击槽位交换、ESC 关闭)
// dev 测试crafttest 第二阶段——合成输出格tick 线程,确定性早于截图)
if *craftTest && !a.craftClickShot {
a.craftClickShot = true
a.handleCraftClick("result")
a.logf("crafttest 第二阶段: 合成输出格2×2 木板 → 工作台)")
}
if a.in.escPressed {
a.closeCrafting()
a.screen = 1
a.requestCursor(true)
a.in.escPressed = false
return
}
if a.in.clicked {
a.handleCraftClick(a.craftScreen.HitTest(a.in.clickX, a.in.clickY, float32(a.winW), float32(a.winH), 3))
a.in.clicked = false
}
a.in.dyaw, a.in.dpitch = 0, 0 // 界面打开时不转动视角
a.sess.Tick(dt)
return
}
// 游戏中
if a.in.escPressed {
@@ -181,6 +207,12 @@ func (a *clientApp) Tick(dt float64) {
return
}
a.in.apply(a.sess)
// 右键命中工作台 → 打开合成界面apply 内 TryOpenCrafting 已置状态)
if a.sess.CraftingOpen() && a.screen == 1 {
a.screen = 3
a.requestCursor(false)
a.logf("打开工作台合成界面")
}
// 手持方块 UV选中槽变化时重算渲染线程消费渲染.md §5.2
if !a.handInit || a.sess.Inv.Selected != a.handSlot {
a.computeHand()
@@ -192,7 +224,7 @@ func (a *clientApp) Tick(dt float64) {
a.sess.Cam.Pitch = *pitchDeg * math.Pi / 180.0
}
if *lookDeg != 0 {
dx := float64(*lookDeg) * math.Pi / 180.0 / 0.0025 // 目标弧度 → 像素位移
dx := float64(*lookDeg) * math.Pi / 180.0 / 0.0025 // 目标弧度 → 像素位移
a.in.cursorCallback(nil, float64(a.winW)/2, float64(a.winH)/2) // 复位基准
a.in.cursorCallback(nil, float64(a.winW)/2+dx, float64(a.winH)/2)
a.logf("lookdeg 注入完成: +%.1f° 水平移动", *lookDeg)
@@ -293,6 +325,82 @@ func (a *clientApp) injectDevVisuals() {
}
a.logf("spawntest 注入: %s 于 (%d,%d,%d)", *spawnTest, ex, gy+1, ez)
}
if *craftTest && !a.craftShot {
a.craftShot = true
// 预填 2×2 木板(槽 0/1/3/4→ 产物为工作台(工作台与合成表.md §5 合成链路)
for _, i := range []int{0, 1, 3, 4} {
a.sess.CraftingSwap(i, item.Stack{Name: "oak_planks", Count: 1})
}
// 热键栏放 16 木板供界面展示与后续合成
a.sess.Inv.Slots[0] = item.Stack{Name: "oak_planks", Count: 16}
a.sess.OpenCrafting()
a.screen = 3
a.requestCursor(false)
a.logf("crafttest 注入: 打开工作台并预填 2×2 木板")
}
}
// handleCraftClick 工作台界面点击:网格/热键栏槽与手持栈交换,输出格执行合成。
func (a *clientApp) handleCraftClick(slot string) {
switch {
case slot == "":
return
case slot == "result":
// 输出格:空手持取走产物;同名手持合并产物继续合成(工作台与合成表.md §5
res, ok := a.sess.CraftingResult()
if !ok {
return
}
if a.held.Empty() {
if r, ok := a.sess.CraftingTakeResult(); ok {
a.held = r
}
} else if a.held.Name == res.Name && a.held.Count < a.held.Limit(a.sess.Items) {
if r, ok := a.sess.CraftingTakeResult(); ok {
merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), a.held, r)
a.held = merged
if !remain.Empty() {
if rem := a.sess.Inv.Add(remain); !rem.Empty() {
a.sess.Entities.SpawnDrop(rem.Name, int(rem.Count), a.sess.Player.Pos[0], a.sess.Player.Pos[1]+1, a.sess.Player.Pos[2])
}
}
}
}
case len(slot) == 5 && slot[:4] == "grid":
i := int(slot[4] - '0')
a.held = a.sess.CraftingSwap(i, a.held)
case len(slot) == 4 && slot[:3] == "hot":
i := int(slot[3] - '0')
if i >= 0 && i < inventory.HotbarSize {
cur := a.sess.Inv.Slots[i]
switch {
case a.held.Empty():
a.sess.Inv.Slots[i] = item.Stack{}
a.held = cur
case cur.Empty():
a.sess.Inv.Slots[i] = a.held
a.held = item.Stack{}
case cur.Name == a.held.Name:
merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), cur, a.held)
a.sess.Inv.Slots[i] = merged
a.held = remain
default:
a.sess.Inv.Slots[i] = a.held
a.held = cur
}
}
}
}
// closeCrafting 关闭合成界面:材料退回背包、手持栈放回背包。
func (a *clientApp) closeCrafting() {
a.sess.CloseCrafting()
if !a.held.Empty() {
if rem := a.sess.Inv.Add(a.held); !rem.Empty() {
a.sess.Entities.SpawnDrop(rem.Name, int(rem.Count), a.sess.Player.Pos[0], a.sess.Player.Pos[1]+1, a.sess.Player.Pos[2])
}
a.held = item.Stack{}
}
}
// requestCursor 请求切换光标捕获模式(由 tick 写入,渲染线程消费——
@@ -353,10 +461,15 @@ func (a *clientApp) Render() {
// 消费光标模式请求(主线程执行 GLFW 调用)
if req := a.cursorReq.Load(); req >= 0 {
a.rend.SetCursorMode(req == 1)
if req == 1 {
// 重新捕获光标跳回窗口中心重置基准防视角瞬跳UI与输入.md §6
a.in.lastX, a.in.lastY = float64(a.winW)/2, float64(a.winH)/2
a.in.firstMouse = true
}
a.cursorReq.Store(-1)
}
// dev 测试:截图前注入破坏裂纹/实体/ESC必须在 3D 绘制之前,截图帧才能拍到)
if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest) {
// dev 测试:截图前注入破坏裂纹/实体/ESC/工作台(必须在 3D 绘制之前,截图帧才能拍到)
if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest) {
a.injectDevVisuals()
}
// 上传 tick 构建好的网格GL 调用必须在渲染线程)
@@ -394,9 +507,11 @@ func (a *clientApp) Render() {
}
// 破坏裂纹叠加(方块交互与动画.md §4progress→destroy_stage 09
if prog := a.sess.BreakProgress(); prog > 0 {
t := a.sess.BreakTarget()
a.rend.DrawCrackOverlay(float32(t.X), float32(t.Y), float32(t.Z), int(prog*10))
if a.screen == 1 {
if prog := a.sess.BreakProgress(); prog > 0 {
t := a.sess.BreakTarget()
a.rend.DrawCrackOverlay(float32(t.X), float32(t.Y), float32(t.Z), int(prog*10))
}
}
// 手持方块上传与绘制(渲染.md §5.2;上传必须是渲染线程)
a.mu.Lock()
@@ -414,7 +529,9 @@ func (a *clientApp) Render() {
a.rend.ClearHand()
}
}
a.rend.DrawHand()
if a.screen == 1 {
a.rend.DrawHand()
}
// UI passUI与输入.md §23D 之后、状态隔离)
a.uiR.Begin()
@@ -427,6 +544,13 @@ func (a *clientApp) Render() {
case 2:
a.hud.Draw(a.uiR) // 暂停时世界仍可见UI还原.md §2
a.pauseScreen.Draw(a.uiR, mx, my)
case 3: // 工作台合成界面UI还原.md §3.4
var grid [9]item.Stack
for i := range grid {
grid[i] = a.sess.CraftSlot(i)
}
res, hasRes := a.sess.CraftingResult()
a.craftScreen.Draw(a.uiR, grid, res, hasRes, a.sess.Inv.Slots[:inventory.HotbarSize], a.held, mx, my, 3)
}
a.uiR.Flush()
a.rend.EndFrame()
@@ -681,9 +805,10 @@ func main() {
uiR.SetFont(fontAtlas)
}
hud := ui.NewHUD(sess, items, 427, 240, guiScale)
var itemTex uint32
itemUV := make(map[string][4]float32, len(itemUVs))
if itemImg != nil {
itemTex := uiR.UploadImage(itemImg)
itemUV := make(map[string][4]float32, len(itemUVs))
itemTex = uiR.UploadImage(itemImg)
for k, rect := range itemUVs {
W, H := float32(itemImg.Bounds().Dx()), float32(itemImg.Bounds().Dy())
itemUV[k] = [4]float32{
@@ -693,6 +818,17 @@ func main() {
}
hud.SetItemAtlas(itemTex, itemUV)
}
// 方块物品图标回退 blocks 图集(原版 block/* 图标不在 items 图集)
blockUITex := uiR.UploadImage(atlasImg)
blockUV := make(map[string][4]float32, len(uvRects))
for k, rect := range uvRects {
W, H := float32(atlasImg.Bounds().Dx()), float32(atlasImg.Bounds().Dy())
blockUV[k] = [4]float32{
float32(rect.Min.X) / W, float32(rect.Min.Y) / H,
float32(rect.Max.X) / W, float32(rect.Max.Y) / H,
}
}
hud.SetBlockAtlas(blockUITex, blockUV)
// 原版 HUD 精灵UI还原.md §3.4gui/sprites/hud/*
loadTex := func(key string) uint32 {
p, ok := am.ResolveTexture(key)
@@ -728,8 +864,16 @@ func main() {
}
titleScreen, pauseScreen := buildScreens(uiR, am, lang)
// 工作台合成界面UI还原.md §3.4gui/container/crafting_table.png + 3×3 网格)
craftScreen := ui.NewCraftingScreen(loadTex("gui/container/crafting_table"), items)
craftScreen.SetItemAtlas(itemTex, itemUV)
craftScreen.SetBlockAtlas(blockUITex, blockUV)
// 6) 输入(鼠标捕获 + 键鼠回调)
in := newInputState(rend.Window())
// 光标初始位置 = 窗口中心(捕获模式下 GLFW 会把光标重置到中心;
// 合成界面手持栈跟随光标,初始化为中心避免出现在左上角)
in.cursorX, in.cursorY = float64(cfg.Client.WindowWidth)/2, float64(cfg.Client.WindowHeight)/2
app := &clientApp{
sess: sess,
@@ -740,6 +884,7 @@ func main() {
hud: hud,
titleScreen: titleScreen,
pauseScreen: pauseScreen,
craftScreen: craftScreen,
log: log,
remotePlayers: make(map[uint32][3]float64),
meshBuilder: mesh.NewBuilder(reg, uvFn),

View File

@@ -204,9 +204,11 @@ func (in *inputState) apply(s *game.Session) {
s.EndBreak()
}
// 放置
// 放置(右键;命中工作台则打开合成界面,工作台与合成表.md §5
if in.placeDown {
s.PlaceSelected()
if !s.TryOpenCrafting() {
s.PlaceSelected()
}
in.placeDown = false
}
}

View File

@@ -50,6 +50,10 @@ type Session struct {
// 创造模式(跳过进度直接破坏)
Creative bool
// 工作台状态(工作台与合成表.md §53×3 网格 + 输出格)
craftOpen bool
craftGrid [9]item.Stack
}
// NewSession 创建会话。
@@ -242,6 +246,133 @@ func (s *Session) overlapsPlayer(x, y, z int32) bool {
float64(z)+1 > pz-hw && float64(z) < pz+hw
}
// ---- 工作台合成(工作台与合成表.md §5----
// craftingTableID 工作台方块 ID注册表查询缓存值
func (s *Session) craftingTableID() uint16 {
id, ok := s.Reg.ID("crafting_table")
if !ok {
return 0
}
return id
}
// TryOpenCrafting 视线 5 格内命中工作台则打开合成界面(右键触发)。
// 返回 true 表示已打开(调用方不应再执行放置)。
func (s *Session) TryOpenCrafting() bool {
h, ok := s.Cast(5)
if !ok {
return false
}
if s.World.Block(h.X, h.Y, h.Z).ID() != s.craftingTableID() {
return false
}
s.craftOpen = true
return true
}
// OpenCrafting 直接打开合成界面(开发测试/后续按键绑定用)。
func (s *Session) OpenCrafting() { s.craftOpen = true }
// CraftingOpen 合成界面是否打开。
func (s *Session) CraftingOpen() bool { return s.craftOpen }
// CraftSlot 读网格槽只读UI 用)。
func (s *Session) CraftSlot(i int) item.Stack {
if i < 0 || i >= 9 {
return item.Stack{}
}
return s.craftGrid[i]
}
// CraftingSwap 网格槽与手持栈交换/合并(同物品合并到上限),返回新的手持栈。
func (s *Session) CraftingSwap(i int, held item.Stack) item.Stack {
if i < 0 || i >= 9 {
return held
}
cur := s.craftGrid[i]
switch {
case held.Empty():
s.craftGrid[i] = item.Stack{}
return cur
case cur.Empty():
s.craftGrid[i] = held
return item.Stack{}
case cur.Name == held.Name:
merged, remain := inventory.Merge(held.Limit(s.Items), cur, held)
s.craftGrid[i] = merged
return remain
default:
s.craftGrid[i] = held
return cur
}
}
// craftResult 按网格匹配配方(有序优先,再无序),返回产物。
func (s *Session) craftResult() (item.Stack, bool) {
var g [3][3]string
counts := make(map[string]int)
for i := 0; i < 9; i++ {
st := s.craftGrid[i]
if st.Empty() {
continue
}
g[i/3][i%3] = st.Name
counts[st.Name]++
}
if len(counts) == 0 {
return item.Stack{}, false
}
// 有序配方优先(避免无序规则误吞有序网格,工作台与合成表.md §4
if rc, ok := s.Recipes.MatchShaped(g); ok {
return item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))}, true
}
if rc, ok := s.Recipes.MatchShapeless(counts); ok {
return item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))}, true
}
return item.Stack{}, false
}
// CraftingResult 当前网格的合成产物(无则 ok=falseUI 输出格用)。
func (s *Session) CraftingResult() (item.Stack, bool) { return s.craftResult() }
// CraftingTakeResult 执行一次合成:消耗网格材料并返回产物。
// 网格匹配失败返回 false材料不动
func (s *Session) CraftingTakeResult() (item.Stack, bool) {
res, ok := s.craftResult()
if !ok {
return item.Stack{}, false
}
for i := range s.craftGrid {
if s.craftGrid[i].Empty() {
continue
}
if s.craftGrid[i].Count <= 1 {
s.craftGrid[i] = item.Stack{}
} else {
s.craftGrid[i].Count--
}
}
return res, true
}
// CloseCrafting 关闭合成界面:网格剩余材料退回背包(放不下掉落地面)。
func (s *Session) CloseCrafting() {
if !s.craftOpen {
return
}
for i := range s.craftGrid {
if s.craftGrid[i].Empty() {
continue
}
if rem := s.Inv.Add(s.craftGrid[i]); !rem.Empty() {
s.Entities.SpawnDrop(rem.Name, int(rem.Count), s.Player.Pos[0], s.Player.Pos[1]+1, s.Player.Pos[2])
}
s.craftGrid[i] = item.Stack{}
}
s.craftOpen = false
}
// tickBlockEntities 熔炉烧炼并产出(物品与背包.md §6
func (s *Session) tickBlockEntities() {
for _, be := range s.BlockEntities {

View File

@@ -169,3 +169,107 @@ func mustID(t *testing.T, reg *block.Registry, name string) uint16 {
}
return id
}
// fillGrid 往合成网格第 i 槽放入 1 个物品(测试辅助)。
func fillGrid(s *Session, i int, name string) {
s.CraftingSwap(i, item.Stack{Name: name, Count: 1})
}
// TestCraftingShaped 工作台有序合成2×2 木板 → 工作台(工作台与合成表.md §4.1/§5
func TestCraftingShaped(t *testing.T) {
s := newTestSession(t)
s.OpenCrafting()
if !s.CraftingOpen() {
t.Fatal("应已打开合成界面")
}
// 空网格无产物
if _, ok := s.CraftingResult(); ok {
t.Fatal("空网格不应有产物")
}
// 2×2 木板
fillGrid(s, 0, "oak_planks")
fillGrid(s, 1, "oak_planks")
fillGrid(s, 3, "oak_planks")
fillGrid(s, 4, "oak_planks")
res, ok := s.CraftingResult()
if !ok || res.Name != "crafting_table" || res.Count != 1 {
t.Fatalf("2×2 木板应产出工作台×1实际 %+v ok=%v", res, ok)
}
// 执行合成:材料消耗、产物返回
taken, ok := s.CraftingTakeResult()
if !ok || taken.Name != "crafting_table" {
t.Fatalf("TakeResult 应返回工作台,实际 %+v", taken)
}
for _, i := range []int{0, 1, 3, 4} {
if !s.CraftSlot(i).Empty() {
t.Fatalf("合成后网格槽 %d 应清空,实际 %+v", i, s.CraftSlot(i))
}
}
if _, ok := s.CraftingResult(); ok {
t.Fatal("材料耗尽后不应再有产物")
}
}
// TestCraftingShapeless 无序合成1 原木 → 4 木板(工作台与合成表.md §4.2)。
func TestCraftingShapeless(t *testing.T) {
s := newTestSession(t)
s.OpenCrafting()
fillGrid(s, 4, "oak_log")
res, ok := s.CraftingResult()
if !ok || res.Name != "oak_planks" || res.Count != 4 {
t.Fatalf("原木应产出 4 木板,实际 %+v ok=%v", res, ok)
}
taken, ok := s.CraftingTakeResult()
if !ok || taken.Count != 4 {
t.Fatalf("应产出 4 木板,实际 %+v", taken)
}
}
// TestCraftingTools 工具配方3 木板 + 2 木棍 → 木镐(工作台与合成表.md §4.1 多符号)。
func TestCraftingTools(t *testing.T) {
s := newTestSession(t)
s.OpenCrafting()
// 顶行 3 木板,中/下行第 2 列木棍
for i := 0; i < 3; i++ {
fillGrid(s, i, "oak_planks")
}
fillGrid(s, 4, "stick")
fillGrid(s, 7, "stick")
res, ok := s.CraftingResult()
if !ok || res.Name != "wooden_pickaxe" {
t.Fatalf("木板+木棍应产出木镐,实际 %+v ok=%v", res, ok)
}
}
// TestCraftingCloseReturn 关闭界面:网格剩余材料退回背包,放不下掉落(工作台与合成表.md §5
func TestCraftingCloseReturn(t *testing.T) {
s := newTestSession(t)
s.Inv.Slots[1] = item.Stack{} // 清空 1 号槽方便计数
s.OpenCrafting()
fillGrid(s, 0, "oak_planks")
fillGrid(s, 0, "oak_planks") // 同槽合并 → 2 个
fillGrid(s, 8, "stick")
s.CloseCrafting()
if s.CraftingOpen() {
t.Fatal("关闭后不应仍处于打开状态")
}
// 网格清空
for i := 0; i < 9; i++ {
if !s.CraftSlot(i).Empty() {
t.Fatalf("关闭后网格槽 %d 应清空", i)
}
}
// 背包收回
havePlanks, haveStick := 0, 0
for _, st := range s.Inv.Slots {
if st.Name == "oak_planks" {
havePlanks += int(st.Count)
}
if st.Name == "stick" {
haveStick += int(st.Count)
}
}
if havePlanks < 2 || haveStick < 1 {
t.Fatalf("材料应退回背包,实际 planks=%d stick=%d", havePlanks, haveStick)
}
}

134
internal/ui/crafting.go Normal file
View File

@@ -0,0 +1,134 @@
//go:build gl
// 工作台界面UI还原.md §3.4、工作台与合成表.md §5
// 176×166 窗口gui/container/crafting_table.png+ 3×3 合成网格 (30,17)
// + 输出格 (124,35) + 热键栏行 (7,141);点击槽位与手持栈交换/合并。
package ui
import (
"fmt"
"mc/internal/item"
)
// 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
// 布局(逻辑 427×240 基准UI还原.md §3.4
winW, winH float32 // 176×166
}
// 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}
}
// SetItemAtlas 设置物品图标纹理与 UV 表(与 HUD 共用 items 图集)。
func (s *CraftingScreen) SetItemAtlas(tex uint32, uv map[string][4]float32) {
s.itemTex = tex
s.itemUV = uv
}
// SetBlockAtlas 设置方块图集(方块物品图标回退来源)。
func (s *CraftingScreen) SetBlockAtlas(tex uint32, uv map[string][4]float32) {
s.blockTex = tex
s.blockUV = uv
}
// 合成台窗口槽位相对窗口左上角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}, // 输出格
}
// HitTest 命中检测:返回 "grid0".."grid8" / "result" / "hot0".."hot8" / ""。
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 i < 9 {
return "grid" + string(rune('0'+i))
}
return "result"
}
}
// 热键栏行(窗口内底部,与物品栏窗口同规格)
for i := 0; i < 9; i++ {
if hit(7+float32(i)*18, 141) {
return "hot" + string(rune('0'+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) {
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
if s.bgTex != 0 {
r.pushQuad(quad{x: bx, y: by, w: s.winW * scale, h: s.winH * scale,
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 {
st = grid[i]
} else if hasResult {
st = result
}
s.drawSlot(r, st, bx+p[0]*scale+1*scale, by+p[1]*scale+1*scale, scale)
}
// 热键栏行
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)
}
// 手持栈跟随鼠标
if !held.Empty() {
s.drawSlot(r, held, mx-8*scale, my-8*scale, scale)
}
}
// 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})
}
}

View File

@@ -15,9 +15,11 @@ type HUD struct {
sess *game.Session
items *item.Registry
// 物品图标items 图集纹理与 UV
itemTex uint32
itemUV map[string][4]float32
// 物品图标items 图集纹理与 UVitem/*);方块物品回退 blocks 图集block/*
itemTex uint32
itemUV map[string][4]float32
blockTex uint32
blockUV map[string][4]float32
// 原版 HUD 精灵UI还原.md §3.4gui/sprites/hud/*
crossTex uint32 // crosshair.png 15×15
@@ -40,7 +42,7 @@ type HUD struct {
// NewHUD 创建 HUD。
func NewHUD(sess *game.Session, items *item.Registry, baseW, baseH, scale float32) *HUD {
return &HUD{sess: sess, items: items, itemUV: map[string][4]float32{}, baseW: baseW, baseH: baseH, scale: scale}
return &HUD{sess: sess, items: items, itemUV: map[string][4]float32{}, blockUV: map[string][4]float32{}, baseW: baseW, baseH: baseH, scale: scale}
}
// SetItemAtlas 设置物品图标纹理与 UV 表items 图集)。
@@ -49,6 +51,13 @@ func (h *HUD) SetItemAtlas(tex uint32, uv map[string][4]float32) {
h.itemUV = uv
}
// SetBlockAtlas 设置方块图集(方块物品图标的回退来源,设计.md §1.3 原版
// block/* 图标在 blocks 图集而非 items 图集)。
func (h *HUD) SetBlockAtlas(tex uint32, uv map[string][4]float32) {
h.blockTex = tex
h.blockUV = uv
}
// SetSprites 设置原版 HUD 精灵纹理(跨 package 上传后注入)。
func (h *HUD) SetSprites(cross, hotbar, hotbarSel, heartFull, heartHalf, heartCont, foodFull, foodHalf uint32) {
h.crossTex, h.hotbarTex, h.hotbarSel = cross, hotbar, hotbarSel
@@ -172,10 +181,17 @@ func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) {
if !ok {
return
}
if uv, found := h.itemUV[def.Texture]; found && h.itemTex != 0 {
// 原版约定block/* 图标在 blocks 图集item/* 在 items 图集
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, y: y, w: 16 * s, h: 16 * s,
u0: uv[0], v0: uv[1], u1: uv[2], v1: uv[3],
r: 1, g: 1, b: 1, a: 1, tex: h.itemTex})
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})

View File

@@ -109,6 +109,7 @@
- **初版默认素材**:嵌入一套原版风格 GUI 纹理(窗口框架、按钮、槽位、心/鸡腿/经验条、热键栏、图标、准星、标题 logo随客户端 `go:embed` 嵌入(设计.md §1.2)。
- **素材包覆盖**:任意 GUI 纹理可用素材包(`type: "resource"`)覆盖 → 天然支持换肤(设计.md §1.3,投放 `mods/`)。
- GUI 纹理统一打包进 **GUI 纹理图集** 减少 draw call渲染.md §4 同套路);字体图集单独管理。
- **物品图标双图集**`item/*` 图标取 items 图集;`block/*` 图标(方块物品,如木板/工作台)取 **blocks 图集**——原版 items 图集不收录 block 纹理,单图集会漏图标。
- 纹理规范16px 基准、PNG、透明按 cutout/translucent 分类(设计.md §2.1)。
## 8. 验收标准(截图对比清单)

View File

@@ -97,3 +97,4 @@
| 日期 | 变更 | 作者 |
| --- | --- | --- |
| (初始建立) | 初版:配方模型、索引、匹配算法、工作台交互、防刷、踩坑清单 | — |
| (合成界面) | 客户端工作台 3×3 界面落地右键命中工作台打开TryOpenCrafting、网格槽位与手持栈交换/合并CraftingSwap、输出格取产物消耗材料CraftingTakeResult、关闭退回材料CloseCraftingUI 规格按 UI还原.md §3.4176×166 窗口 + 网格 (30,17) + 输出 (124,35)Shift 批量合成与配方书后置 | — |

View File

@@ -206,3 +206,4 @@
| (原生素材包+命名) | 素材包原生 pack.mcmeta 格式直接支持(设计.md §1.2/§1.3);资源路径对齐原版命名;项目名「年糕历险记」+ NSIS 中文安装(构建与发布.md §8 | — |
| (实现里程碑) | M0M3、M5 完成M3.5/M4/M6 进行中GL 客户端构建运行、Mod 热更新、多人同步 MVP、中文 UI详见 路线图.md 状态) | — |
| (客户端渲染修复) | 渲染.md 新增 §4.1 植被染色(原版灰度草/叶图集染色、§5.2 覆盖层渲染(破坏裂纹/手持方块、§5.3 实体广告牌修复相机视图矩阵俯仰符号翻转视角不跟随鼠标根因HUD 换原版心/鸡腿/热键栏精灵 | — |
| (工作台界面) | 客户端工作台 3×3 合成界面落地(工作台与合成表.md §5右键命中工作台打开、槽位交换/合并、输出格合成、关闭退回材料UI还原.md §7 补物品图标双图集规则block/* 取 blocks 图集) | — |

View File

@@ -11,10 +11,10 @@
| M1 世界 | 区块、确定性生成、光照、存档 | 已完成 |
| M2 渲染 | 网格、图集、透明排序、视锥剔除 | 已完成 |
| M3 游戏性 | 方块交互、物理、背包、配方、实体 | 已完成(破坏裂纹叠加与手持方块渲染已补齐;合成台/熔炉 GUI 后置) |
| M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(实体 AI/僵尸/村民/工作台数据/客户端实体渲染已完成;村民交易与动物繁殖后置) |
| M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(实体 AI/僵尸/村民/工作台数据与 3×3 合成界面/客户端实体渲染已完成;村民交易与动物繁殖后置) |
| M4 多人 | 网络协议、同步、防作弊 | 进行中(协议/服务器/客户端网络层/区块流/方块广播/实体位置广播/心跳已完成;插值渲染与完整防作弊后置) |
| M5 Mod | 加载器、Lua API、沙箱、资源合并 | 已完成 |
| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频/UI 精灵与主菜单/光影包/存档/安装脚本已完成;合成台与背包界面、性能池化与安装包构建后置) |
| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频/UI 精灵与主菜单/光影包/存档/安装脚本/合成台界面已完成;背包与熔炉界面、性能池化与安装包构建后置) |
## M0 骨架