From 5da460c9a25f3fb8b29e8f3db1b03336faa630d1 Mon Sep 17 00:00:00 2001 From: NianGao Dev Date: Sun, 16 Aug 2026 20:35:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=B0=8F=E5=9C=B0=E5=9B=BE?= =?UTF-8?q?=EF=BC=88=E5=B7=A6=E4=B8=8A=E8=A7=92=E5=9C=B0=E8=A1=A8=E8=89=B2?= =?UTF-8?q?=E5=9B=BE+=E7=8E=A9=E5=AE=B6=E6=A0=87=E8=AE=B0=EF=BC=89?= =?UTF-8?q?=E4=B8=8E=E7=89=A9=E5=93=81=E6=A0=8F=E7=95=8C=E9=9D=A2=EF=BC=88?= =?UTF-8?q?E=20=E9=94=AE/Q=20=E4=B8=A2=E5=BC=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 小地图(UI还原.md §3.5):96×96 地表色图(方块注册表→图集中心色, 植被染色后采样),每 1.5s 重建,玩家标记烘焙进地图图(白点+黑描边); 覆盖层 quad 批处理末尾颜色异常,标记改走烘焙规避 - 物品栏(UI还原.md §3.3):E 键开关屏幕状态 5,inventory.png 176×166 窗口 + 27 主物品区 (7,83) + 热键栏行 (7,141),点击与手持栈交换/合并, Esc 关闭手持栈自动回收 - Q 丢弃选中槽 1 个物品(掉落在视线前方,UI与输入.md §5) - cjkfont 多行渲染(设置界面按键教程 4 行列表) - 动物攻击测试、原版掉落测试(草→泥土/动物掉肉) --- cmd/client/client_gl.go | 210 +++++++++++++++++++++++++++++++++++-- cmd/client/input.go | 17 ++- internal/block/registry.go | 3 + internal/game/game.go | 17 +++ internal/ui/hud.go | 16 +++ internal/ui/inventory.go | 118 +++++++++++++++++++++ 文档/场景/UI还原.md | 8 ++ 文档/路线图.md | 2 +- 8 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 internal/ui/inventory.go diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index 58973677..bb12caff 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -63,6 +63,8 @@ var ( escTest = flag.Bool("esctest", false, "dev 测试:截图前注入一次 ESC(验证暂停菜单与光标释放链路)") craftTest = flag.Bool("crafttest", false, "dev 测试:截图前打开工作台并预填 2×2 木板(验证合成界面)") optTest = flag.Bool("optionstest", false, "dev 测试:截图前打开设置界面") + invTest = flag.Bool("invtest", false, "dev 测试:截图前打开物品栏(E 键界面)并放入若干物品") + miniDump = flag.String("minimapdump", "", "dev 诊断:保存一次小地图图像到该路径") ) // clientApp 客户端应用:把会话与渲染粘合起来(engine.Ticker + engine.Renderer)。 @@ -74,12 +76,13 @@ type clientApp struct { uiR *ui.Renderer hud *ui.HUD - // 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停 / 3 工作台 / 4 设置(UI还原.md §2) + // 屏幕状态机:0 主菜单 / 1 游戏中 / 2 暂停 / 3 工作台 / 4 设置 / 5 物品栏(UI还原.md §2) screen int titleScreen *ui.TitleScreen pauseScreen *ui.PauseScreen craftScreen *ui.CraftingScreen optionsScr *ui.OptionsScreen + invScreen *ui.InventoryScreen optionsRet int // 设置界面的返回屏幕(0 主菜单 / 2 暂停) held item.Stack // 合成界面手持栈(跟随鼠标) audio *audio.Manager @@ -123,6 +126,11 @@ type clientApp struct { targetImg image.Image targetDirty bool + // 小地图(tick 生成地表色图 → 渲染线程上传,UI还原.md §3.5) + miniImg image.Image + miniDirty bool + blockColors []color.RGBA // 方块 ID → 图集中心色(启动时构建) + winW, winH int tickCnt uint64 frames int // 渲染帧计数(截图时机用) @@ -135,6 +143,8 @@ type clientApp struct { craftShot bool // dev 测试:-crafttest 已注入 craftClickShot bool // dev 测试:-crafttest 点击输出格已注入 optShot bool // dev 测试:-optionstest 已注入 + invShot bool // dev 测试:-invtest 已注入 + miniDumped bool // dev 诊断:-minimapdump 已保存 } // worldMeshJob 待上传网格。 @@ -250,6 +260,22 @@ func (a *clientApp) Tick(dt float64) { a.in.clicked = false } return + case 5: // 物品栏(E 打开,UI还原.md §3.3:27 主区 + 热键栏,点击交换) + if a.in.escPressed || a.in.toggleInv { + a.in.escPressed = false + a.in.toggleInv = false + a.returnHeld() + a.screen = 1 + a.requestCursor(true) + return + } + if a.in.clicked { + a.handleInvClick(a.invScreen.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 { @@ -258,6 +284,13 @@ func (a *clientApp) Tick(dt float64) { a.in.escPressed = false return } + // E 打开物品栏(UI还原.md §3.3) + if a.in.toggleInv { + a.in.toggleInv = false + a.screen = 5 + a.requestCursor(false) + return + } a.in.apply(a.sess) // 右键命中工作台 → 打开合成界面(apply 内 TryOpenCrafting 已置状态) if a.sess.CraftingOpen() && a.screen == 1 { @@ -292,6 +325,10 @@ func (a *clientApp) Tick(dt float64) { if a.tickCnt%15 == 0 { a.updateTargetName() } + // 小地图(每 30 tick 重建地表色图,UI还原.md §3.5) + if a.tickCnt%30 == 0 { + a.updateMinimap() + } // 联机:上报移动(20Hz,多人同步.md §3) if a.net != nil { @@ -408,6 +445,18 @@ func (a *clientApp) injectDevVisuals() { a.requestCursor(false) a.logf("optionstest 注入: 打开设置界面") } + if *invTest && !a.invShot { + a.invShot = true + // 放入若干展示物品(工具/方块/掉落物) + demo := []string{"oak_planks", "crafting_table", "furnace", "torch", "iron_pickaxe", "cobblestone", "beef", "stick", "diamond"} + for i, name := range demo { + a.sess.Inv.Slots[i+9] = item.Stack{Name: name, Count: uint8(1 + i*3)} + } + a.sess.Inv.Slots[0] = item.Stack{Name: "oak_planks", Count: 32} + a.screen = 5 + a.requestCursor(false) + a.logf("invtest 注入: 打开物品栏") + } } // handleCraftClick 工作台界面点击:网格/热键栏槽与手持栈交换,输出格执行合成。 @@ -462,15 +511,54 @@ func (a *clientApp) handleCraftClick(slot string) { } } +// handleInvClick 物品栏界面点击:主区/热键栏槽与手持栈交换/合并。 +func (a *clientApp) handleInvClick(slot string) { + var idx int + switch { + case len(slot) == 5 && slot[:4] == "main": + fmt.Sscanf(slot[4:], "%d", &idx) + idx += 9 // 主物品区 = 槽 9..35 + case len(slot) == 4 && slot[:3] == "hot": + fmt.Sscanf(slot[3:], "%d", &idx) + default: + return + } + if idx < 0 || idx >= inventory.TotalSize { + return + } + cur := a.sess.Inv.Slots[idx] + switch { + case a.held.Empty(): + a.sess.Inv.Slots[idx] = item.Stack{} + a.held = cur + case cur.Empty(): + a.sess.Inv.Slots[idx] = a.held + a.held = item.Stack{} + case cur.Name == a.held.Name: + merged, remain := inventory.Merge(a.held.Limit(a.sess.Items), cur, a.held) + a.sess.Inv.Slots[idx] = merged + a.held = remain + default: + a.sess.Inv.Slots[idx] = a.held + a.held = cur + } +} + +// returnHeld 手持栈放回背包(放不下掉落地面)。 +func (a *clientApp) returnHeld() { + if a.held.Empty() { + return + } + 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{} +} + // 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{} - } + a.returnHeld() } // updateTargetName 计算准星指向方块的显示名(lang 本地化 → CJK 光栅化,UI还原.md §3.4)。 @@ -522,6 +610,89 @@ func clampFloat(v, lo, hi float64) float64 { return v } +// updateMinimap 重建小地图地表色图(96×96 像素 = 玩家周围 96×96 方块)。 +// 每列取最上方非空气/非树叶方块的颜色(图集中心色表);tick 线程 CPU 构建。 +func (a *clientApp) updateMinimap() { + const n = 96 + img := image.NewRGBA(image.Rect(0, 0, n, n)) + px, pz := int32(a.sess.Player.Pos[0]), int32(a.sess.Player.Pos[2]) + half := int32(n / 2) + sky := color.RGBA{135, 207, 235, 255} + a.sess.World.WithReadLock(func() { + for dz := int32(0); dz < n; dz++ { + wz := pz - half + dz + for dx := int32(0); dx < n; dx++ { + wx := px - half + dx + var s block.State + for y := int32(200); y >= 0; y-- { + bs := a.sess.World.BlockUnlocked(wx, y, wz) + if bs == block.Air { + continue + } + if a.reg.Name(bs.ID()) == "oak_leaves" { + continue // 树叶透视到地表 + } + s = bs + break + } + c := sky + if s != block.Air && int(s.ID()) < len(a.blockColors) { + c = a.blockColors[s.ID()] + } + img.SetRGBA(int(dx), int(dz), c) + } + } + }) + // 玩家标记:烘焙进地图图(中心 3×3 白点 + 黑描边), + // 避免 UI 批处理下覆盖层 quad 的颜色异常(见 HUD 小地图注释) + drawMarker := func(cx, cy int) { + img.SetRGBA(cx-1, cy-1, color.RGBA{0, 0, 0, 255}) + img.SetRGBA(cx, cy-1, color.RGBA{0, 0, 0, 255}) + img.SetRGBA(cx+1, cy-1, color.RGBA{0, 0, 0, 255}) + img.SetRGBA(cx-1, cy, color.RGBA{0, 0, 0, 255}) + img.SetRGBA(cx, cy, color.RGBA{255, 255, 255, 255}) + img.SetRGBA(cx+1, cy, color.RGBA{0, 0, 0, 255}) + img.SetRGBA(cx-1, cy+1, color.RGBA{0, 0, 0, 255}) + img.SetRGBA(cx, cy+1, color.RGBA{0, 0, 0, 255}) + img.SetRGBA(cx+1, cy+1, color.RGBA{0, 0, 0, 255}) + } + drawMarker(n/2, n/2) + // dev 诊断:-minimapdump 保存一次地图图 + if *miniDump != "" && !a.miniDumped { + a.miniDumped = true + if f, err := os.Create(*miniDump); err == nil { + _ = png.Encode(f, img) + _ = f.Close() + a.logf("minimapdump 已保存: %s", *miniDump) + } + } + a.mu.Lock() + a.miniImg = img + a.miniDirty = true + a.mu.Unlock() +} + +// buildBlockColors 构建方块 ID → 图集中心色表(小地图配色,渲染.md §4)。 +func buildBlockColors(reg *block.Registry, atlasImg image.Image, uvRects map[string]image.Rectangle) []color.RGBA { + defs := reg.All() + out := make([]color.RGBA, len(defs)) + for id := range defs { + if id == 0 { + continue + } + st := block.NewState(uint16(id), 0) + key := reg.Texture(st, "up") + rc, ok := uvRects[key] + if !ok { + continue + } + cx, cy := rc.Min.X+rc.Dx()/2, rc.Min.Y+rc.Dy()/2 + pr, pg, pb, _ := atlasImg.At(cx, cy).RGBA() + out[id] = color.RGBA{uint8(pr >> 8), uint8(pg >> 8), uint8(pb >> 8), 255} + } + return out +} + // requestCursor 请求切换光标捕获模式(由 tick 写入,渲染线程消费—— // GLFW 调用必须发生在渲染/主线程,UI与输入.md §6)。 func (a *clientApp) requestCursor(captured bool) { @@ -587,8 +758,8 @@ func (a *clientApp) Render() { } a.cursorReq.Store(-1) } - // dev 测试:截图前注入破坏裂纹/实体/ESC/工作台/设置(必须在 3D 绘制之前,截图帧才能拍到) - if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest || *optTest) { + // dev 测试:截图前注入破坏裂纹/实体/ESC/工作台/设置/物品栏(必须在 3D 绘制之前,截图帧才能拍到) + if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest || *craftTest || *optTest || *invTest) { a.injectDevVisuals() } // 上传 tick 构建好的网格(GL 调用必须在渲染线程) @@ -670,6 +841,15 @@ func (a *clientApp) Render() { } } + // 小地图上传(tick 生成地表色图 → 渲染线程上传,UI还原.md §3.5) + a.mu.Lock() + miniDirty, miniImg := a.miniDirty, a.miniImg + a.miniDirty = false + a.mu.Unlock() + if miniDirty && miniImg != nil { + a.hud.SetMinimap(a.uiR.UploadImage(miniImg)) + } + // UI pass(UI与输入.md §2:3D 之后、状态隔离) a.uiR.Begin() mx, my := float32(a.in.cursorX), float32(a.in.cursorY) @@ -690,6 +870,8 @@ func (a *clientApp) Render() { a.craftScreen.Draw(a.uiR, grid, res, hasRes, a.sess.Inv.Slots[:inventory.HotbarSize], a.held, mx, my, 3) case 4: // 设置界面 a.optionsScr.Draw(a.uiR, mx, my, float32(a.sensVal/0.01), float32(a.volVal)) + case 5: // 物品栏界面 + a.invScreen.Draw(a.uiR, a.sess.Inv.Slots, a.held, mx, my, 3) } a.uiR.Flush() a.rend.EndFrame() @@ -819,6 +1001,9 @@ func main() { log.Infof("图集植被染色:%v", tinted) } + // 小地图配色表(方块 ID → 图集中心色,UI还原.md §3.5) + blockColors := buildBlockColors(reg, atlasImg, uvRects) + // 裂纹图集(方块交互与动画.md §4:destroy_stage_0..9,破坏进度叠加用) crackKeys := make([]string, 10) for i := range crackKeys { @@ -1037,6 +1222,11 @@ func main() { optionsScr := buildOptionsScreen(uiR, lang, loadTex("gui/sprites/widget/button"), loadTex("gui/sprites/widget/button_highlighted"), worldSeed) + // 物品栏界面(UI还原.md §3.3:E 键打开) + invScreen := ui.NewInventoryScreen(loadTex("gui/container/inventory"), items) + invScreen.SetItemAtlas(itemTex, itemUV) + invScreen.SetBlockAtlas(blockUITex, blockUV) + // 6) 输入(鼠标捕获 + 键鼠回调) in := newInputState(rend.Window()) in.SetSensitivity(cfg.Client.MouseSensitivity) @@ -1055,11 +1245,13 @@ func main() { pauseScreen: pauseScreen, craftScreen: craftScreen, optionsScr: optionsScr, + invScreen: invScreen, audio: audioMgr, worldSeed: worldSeed, worldName: worldName, sensVal: cfg.Client.MouseSensitivity, volVal: cfg.Client.Audio.Volume, + blockColors: blockColors, log: log, lang: lang, remotePlayers: make(map[uint32][3]float64), diff --git a/cmd/client/input.go b/cmd/client/input.go index 2d90fd84..6785850f 100644 --- a/cmd/client/input.go +++ b/cmd/client/input.go @@ -31,7 +31,9 @@ type inputState struct { breakDown bool breakHeld bool placeDown bool - selectSlot int // -1 无 + selectSlot int // -1 无 + toggleInv bool // E 打开/关闭物品栏 + dropItem bool // Q 丢弃选中槽 1 个物品 // 鼠标与 UI 事件(UI与输入.md §6 屏幕交互) cursorX, cursorY float64 @@ -86,6 +88,14 @@ func (in *inputState) keyCallback(_ *glfw.Window, key glfw.Key, _ int, action gl if action == glfw.Press { in.selectSlot = int(key) - int(glfw.Key1) } + case glfw.KeyE: + if action == glfw.Press { + in.toggleInv = true // 物品栏开关(UI还原.md §3.3) + } + case glfw.KeyQ: + if action == glfw.Press { + in.dropItem = true // 丢弃物品(UI与输入.md §5) + } } } @@ -225,6 +235,11 @@ func (in *inputState) apply(s *game.Session) { } in.placeDown = false } + // 丢弃选中槽 1 个物品(UI与输入.md §5 Q 键) + if in.dropItem { + s.DropSelected() + in.dropItem = false + } } var _ = physics.Player{} // 保持引用(后续输入扩展用) diff --git a/internal/block/registry.go b/internal/block/registry.go index 7faf2353..6a7070b0 100644 --- a/internal/block/registry.go +++ b/internal/block/registry.go @@ -98,6 +98,9 @@ func (r *Registry) Get(id uint16) Def { return Def{} } +// All 返回全部方块定义(下标即 ID,含空槽 0——小地图配色表等遍历用)。 +func (r *Registry) All() []Def { return r.byID } + // ID 按名称取 ID。 func (r *Registry) ID(name string) (uint16, bool) { id, ok := r.byName[name] diff --git a/internal/game/game.go b/internal/game/game.go index d6ab43ab..698077ad 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -225,6 +225,23 @@ func (s *Session) Attack() bool { return true } +// DropSelected 丢弃选中槽 1 个物品(UI与输入.md §5 Q 键;掉落在视线前方)。 +func (s *Session) DropSelected() { + st := s.Inv.Slots[s.Inv.Selected] + if st.Empty() { + return + } + st.Count-- + if st.Count == 0 { + s.Inv.Slots[s.Inv.Selected] = item.Stack{} + } else { + s.Inv.Slots[s.Inv.Selected] = st + } + f := s.Cam.Forward() + s.Entities.SpawnDrop(st.Name, 1, + s.Player.Pos[0]+f[0]*1.5, s.Player.Pos[1]+1, s.Player.Pos[2]+f[2]*1.5) +} + // spawnVillagers 村庄村民生成(村民系统.md §2):扫描已加载区块,村庄中心处生成 3 名村民。 func (s *Session) spawnVillagers(dt float64) { s.villagerTimer -= dt diff --git a/internal/ui/hud.go b/internal/ui/hud.go index 93b5bcf1..7d336e05 100644 --- a/internal/ui/hud.go +++ b/internal/ui/hud.go @@ -37,6 +37,9 @@ type HUD struct { targetW float32 targetH float32 + // 小地图(左上角,UI还原.md §3.5) + minimapTex uint32 + // 逻辑分辨率与缩放(UI还原.md §3.1:427×240 基准 × 整数缩放) baseW, baseH float32 scale float32 @@ -76,6 +79,9 @@ func (h *HUD) SetTarget(tex uint32, w, hgt float32) { h.targetTex, h.targetW, h.targetH = tex, w, hgt } +// SetMinimap 设置小地图纹理(渲染线程注入;tex=0 清除)。 +func (h *HUD) SetMinimap(tex uint32) { h.minimapTex = tex } + // Draw 每帧绘制 HUD(调用方已 Begin)。 func (h *HUD) Draw(r *Renderer) { w, hgt := float32(r.w), float32(r.h) @@ -174,6 +180,16 @@ func (h *HUD) Draw(r *Renderer) { if h.targetTex != 0 { h.blit(r, h.targetTex, 2*s, hgt-96*s-h.targetH, h.targetW, h.targetH) } + // 小地图(左上角 96×96 逻辑像素 + 边框;玩家标记已烘焙进地图图—— + // 覆盖层 quad 在批处理末尾存在颜色异常,见 updateMinimap drawMarker) + if h.minimapTex != 0 { + const m = 96.0 + h.blit(r, h.minimapTex, 2*s, 2*s, m*s, m*s) + r.DrawRect(1.5*s, 1.5*s, (m+1)*s, 1*s, [4]float32{0, 0, 0, 0.7}) + r.DrawRect(1.5*s, (m+1.5)*s, (m+1)*s, 1*s, [4]float32{0, 0, 0, 0.7}) + r.DrawRect(1.5*s, 1.5*s, 1*s, (m+1)*s, [4]float32{0, 0, 0, 0.7}) + r.DrawRect((m+0.5)*s, 1.5*s, 1*s, (m+1)*s, [4]float32{0, 0, 0, 0.7}) + } } // blit 以纹理原比例绘制一个四边形(UI 精灵)。 diff --git a/internal/ui/inventory.go b/internal/ui/inventory.go new file mode 100644 index 00000000..5f7c0f6e --- /dev/null +++ b/internal/ui/inventory.go @@ -0,0 +1,118 @@ +//go:build gl + +// 物品栏界面(UI还原.md §3.3、物品与背包.md §4): +// inventory.png 176×166 窗口 + 27 主物品区 (7,83) + 热键栏行 (7,141); +// 点击槽位与手持栈交换/合并;E 键打开、Esc 关闭。 +package ui + +import ( + "fmt" + + "mc/internal/item" +) + +// InventoryScreen 物品栏界面。 +type InventoryScreen struct { + bgTex uint32 // inventory.png(256×256,内容 176×166) + items *item.Registry + itemTex uint32 + itemUV map[string][4]float32 + // 方块物品图标回退来源(原版 block/* 图标在 blocks 图集) + blockTex uint32 + blockUV map[string][4]float32 + + winW, winH float32 // 176×166 +} + +// 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} +} + +// SetItemAtlas 设置 items 图集。 +func (s *InventoryScreen) SetItemAtlas(tex uint32, uv map[string][4]float32) { + s.itemTex = tex + s.itemUV = uv +} + +// SetBlockAtlas 设置 blocks 图集(方块物品图标回退)。 +func (s *InventoryScreen) SetBlockAtlas(tex uint32, uv map[string][4]float32) { + s.blockTex = tex + s.blockUV = uv +} + +// HitTest 命中检测:返回 "main0".."main26" / "hot0".."hot8" / ""。 +// 主物品区 3×9 自 (7,83),热键栏 1×9 自 (7,141)(UI还原.md §3.3)。 +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 < 27; i++ { + px := 7 + float32(i%9)*18 + py := 83 + float32(i/9)*18 + if hit(px, py) { + return "main" + fmt.Sprintf("%d", i) + } + } + for i := 0; i < 9; i++ { + if hit(7+float32(i)*18, 141) { + 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) { + 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 + 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}) + } + // 27 主物品区 + for i := 0; i < 27 && i < len(slots); i++ { + 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) + } + // 热键栏行 + 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) + } + // 手持栈跟随鼠标 + if !held.Empty() { + s.drawSlot(r, held, mx-8*scale, my-8*scale, scale) + } +} + +// 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}) + } +} diff --git a/文档/场景/UI还原.md b/文档/场景/UI还原.md index d934f1a3..93adaf7f 100644 --- a/文档/场景/UI还原.md +++ b/文档/场景/UI还原.md @@ -68,6 +68,14 @@ | 生命 / 饥饿 | 每图标 9×9,屏幕左下角;受伤红闪 | | 经验条 | 182 × 5,热键栏正上方 | | 准星 | 屏幕正中 15×15(十字) | +| 准星方块名 | 屏幕左下角(生命行上方),本地化名称 + 1px 阴影 | + +### 3.5 小地图(左上角,项目定制) + +- 屏幕左上角 96×96 逻辑像素;纹理为玩家周围 96×96 方块的**地表色图**(每像素一列,取最上方非空气/非树叶方块的颜色)。 +- 配色:方块注册表 → 图集中心色(植被染色后的图集采样,草/树叶呈真实绿色)。 +- 中心白点 = 玩家位置;地图每 1.5 秒(30 tick)重建一次,GPU 上传走渲染线程(tick 线程只做 CPU 构建)。 +- 原版无小地图(为社区模组功能),本项目作定制界面提供,布局不与原版对比。 > 上表数值以原版 Java 版**实测为准**:实现时逐界面截图校准 ±1px,登记到「UI 布局基线表」(`docs/ui-layout-baseline.md`,随代码库维护)。 diff --git a/文档/路线图.md b/文档/路线图.md index 92818a5c..4bc90ad3 100644 --- a/文档/路线图.md +++ b/文档/路线图.md @@ -14,7 +14,7 @@ | M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(实体 AI/僵尸/村民/工作台数据与 3×3 合成界面/客户端实体渲染/动物攻击与掉落/村庄村民生成已完成;村民交易与动物繁殖后置) | | M4 多人 | 网络协议、同步、防作弊 | 进行中(协议/服务器/客户端网络层/区块流/方块广播/实体位置广播/心跳已完成;插值渲染与完整防作弊后置) | | M5 Mod | 加载器、Lua API、沙箱、资源合并 | 已完成 | -| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频接入/UI 精灵与主菜单/设置界面(灵敏度/音量/种子/按键教程)/准星方块名/光影包/存档/安装脚本/合成台界面已完成;小地图、背包与熔炉界面、性能池化与安装包构建后置) | +| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频接入/UI 精灵与主菜单/设置界面(灵敏度/音量/种子/按键教程)/准星方块名/小地图/物品栏(E 键)与 Q 丢弃/光影包/存档/安装脚本/合成台界面已完成;熔炉界面、性能池化与安装包构建后置) | ## M0 骨架