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:
@@ -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),
|
||||
|
||||
@@ -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{} // 保持引用(后续输入扩展用)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 精灵)。
|
||||
|
||||
118
internal/ui/inventory.go
Normal file
118
internal/ui/inventory.go
Normal file
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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`,随代码库维护)。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| M3.5 生态与文明 | 动物、村民(会建造)、僵尸敌对 | 进行中(实体 AI/僵尸/村民/工作台数据与 3×3 合成界面/客户端实体渲染/动物攻击与掉落/村庄村民生成已完成;村民交易与动物繁殖后置) |
|
||||
| M4 多人 | 网络协议、同步、防作弊 | 进行中(协议/服务器/客户端网络层/区块流/方块广播/实体位置广播/心跳已完成;插值渲染与完整防作弊后置) |
|
||||
| M5 Mod | 加载器、Lua API、沙箱、资源合并 | 已完成 |
|
||||
| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频接入/UI 精灵与主菜单/设置界面(灵敏度/音量/种子/按键教程)/准星方块名/光影包/存档/安装脚本/合成台界面已完成;小地图、背包与熔炉界面、性能池化与安装包构建后置) |
|
||||
| M6 打磨 | 音频、UI、性能、崩溃恢复、发布 | 进行中(音频接入/UI 精灵与主菜单/设置界面(灵敏度/音量/种子/按键教程)/准星方块名/小地图/物品栏(E 键)与 Q 丢弃/光影包/存档/安装脚本/合成台界面已完成;熔炉界面、性能池化与安装包构建后置) |
|
||||
|
||||
## M0 骨架
|
||||
|
||||
|
||||
Reference in New Issue
Block a user