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:
@@ -59,6 +59,7 @@ var (
|
||||
crackStage = flag.Int("crackstage", -1, "dev 测试:截图前把破坏进度视觉设置为该阶段(0–9)")
|
||||
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 §4:progress→destroy_stage 0–9)
|
||||
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 pass(UI与输入.md §2:3D 之后、状态隔离)
|
||||
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.4:gui/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.4:gui/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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ type Session struct {
|
||||
|
||||
// 创造模式(跳过进度直接破坏)
|
||||
Creative bool
|
||||
|
||||
// 工作台状态(工作台与合成表.md §5:3×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=false,UI 输出格用)。
|
||||
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 {
|
||||
|
||||
@@ -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
134
internal/ui/crafting.go
Normal 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.png(256×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 绘制工作台界面(调用方已 Begin;mx/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×166,UI还原.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})
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,11 @@ type HUD struct {
|
||||
sess *game.Session
|
||||
items *item.Registry
|
||||
|
||||
// 物品图标:items 图集纹理与 UV
|
||||
itemTex uint32
|
||||
itemUV map[string][4]float32
|
||||
// 物品图标:items 图集纹理与 UV(item/*);方块物品回退 blocks 图集(block/*)
|
||||
itemTex uint32
|
||||
itemUV map[string][4]float32
|
||||
blockTex uint32
|
||||
blockUV map[string][4]float32
|
||||
|
||||
// 原版 HUD 精灵(UI还原.md §3.4:gui/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})
|
||||
|
||||
@@ -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. 验收标准(截图对比清单)
|
||||
|
||||
@@ -97,3 +97,4 @@
|
||||
| 日期 | 变更 | 作者 |
|
||||
| --- | --- | --- |
|
||||
| (初始建立) | 初版:配方模型、索引、匹配算法、工作台交互、防刷、踩坑清单 | — |
|
||||
| (合成界面) | 客户端工作台 3×3 界面落地:右键命中工作台打开(TryOpenCrafting)、网格槽位与手持栈交换/合并(CraftingSwap)、输出格取产物消耗材料(CraftingTakeResult)、关闭退回材料(CloseCrafting);UI 规格按 UI还原.md §3.4(176×166 窗口 + 网格 (30,17) + 输出 (124,35));Shift 批量合成与配方书后置 | — |
|
||||
|
||||
1
文档/索引.md
1
文档/索引.md
@@ -206,3 +206,4 @@
|
||||
| (原生素材包+命名) | 素材包原生 pack.mcmeta 格式直接支持(设计.md §1.2/§1.3);资源路径对齐原版命名;项目名「年糕历险记」+ NSIS 中文安装(构建与发布.md §8) | — |
|
||||
| (实现里程碑) | M0–M3、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 图集) | — |
|
||||
|
||||
@@ -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 骨架
|
||||
|
||||
|
||||
Reference in New Issue
Block a user