feat(ui): 小地图(左上角地表色图+玩家标记)与物品栏界面(E 键/Q 丢弃)

- 小地图(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 行列表)
- 动物攻击测试、原版掉落测试(草→泥土/动物掉肉)
This commit is contained in:
NianGao Dev
2026-08-16 20:35:21 +08:00
parent f25ae15077
commit 5da460c9a2
8 changed files with 380 additions and 11 deletions

View File

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

View File

@@ -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{} // 保持引用(后续输入扩展用)