feat(client): 植被染色、破坏裂纹叠加、手持方块与实体广告牌渲染;原版 HUD 精灵

- 植被染色(渲染.md §4.1):blocks.json 增 tint 标记,图集构建后按
  colormap/grass|foliage 中心色乘色——原版材质包草顶/树叶为灰度图,不染色全灰
- 破坏裂纹(方块交互与动画.md §4):destroy_stage_0..9 裂纹盒叠加破坏目标方块
- 第一人称手持方块(渲染.md §5.2):选中槽放置方块六面 UV 视图空间立方体
- 实体广告牌(渲染.md §5.3):僵尸/村民/动物按贴图渲染,动物按 ID 轮换外观
- HUD 换原版精灵:crosshair/hotbar/hotbar_selection/heart/food(UI还原.md §3.4)
- 主菜单标题/按钮标签按请求字号光栅化(修复 24px 固定字号);版本角标 CJK
- 恢复背面剔除(相机修复后 '脚下黑块' 误诊解除);ESC 光标请求原子化防竞态
- dev 测试参数:-give/-crackstage/-spawntest/-esctest
This commit is contained in:
NianGao Dev
2026-08-16 10:08:40 +08:00
parent 108c803a75
commit d52bf1126f
16 changed files with 940 additions and 60 deletions

3
.gitignore vendored
View File

@@ -6,3 +6,6 @@ tools/mingw64/
*.exe
*.tmp
*.log
# 本地构建缓存(沙箱环境下 GOCACHE/zig cache 重定向到工作区)
.gocache/
.zigcache/

View File

@@ -4,7 +4,7 @@
"granite": { "id": 2, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/granite" } },
"diorite": { "id": 3, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/diorite" } },
"andesite": { "id": 4, "hardness": 1.5, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/andesite" } },
"grass_block": { "id": 5, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/grass_block_top", "down": "block/dirt", "side": "block/grass_block_side" } },
"grass_block": { "id": 5, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/grass_block_top", "down": "block/dirt", "side": "block/grass_block_side" }, "tint": "grass" },
"dirt": { "id": 6, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/dirt" } },
"cobblestone": { "id": 7, "hardness": 2.0, "resistance": 6.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/cobblestone" } },
"oak_planks": { "id": 8, "hardness": 2.0, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/oak_planks" } },
@@ -12,7 +12,7 @@
"sand": { "id": 10, "hardness": 0.5, "resistance": 2.5, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/sand" } },
"gravel": { "id": 11, "hardness": 0.6, "resistance": 3.0, "light": 0, "transparent": false, "solid": true, "textures": { "all": "block/gravel" } },
"oak_log": { "id": 12, "hardness": 2.0, "resistance": 2.0, "light": 0, "transparent": false, "solid": true, "textures": { "up": "block/oak_log_top", "down": "block/oak_log_top", "side": "block/oak_log" } },
"oak_leaves": { "id": 13, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/oak_leaves" }, "cutout": true },
"oak_leaves": { "id": 13, "hardness": 0.2, "resistance": 1.0, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/oak_leaves" }, "cutout": true, "tint": "foliage" },
"glass": { "id": 14, "hardness": 0.3, "resistance": 1.5, "light": 0, "transparent": true, "solid": true, "textures": { "all": "block/glass" }, "cutout": true },
"water": { "id": 15, "hardness": 100.0, "resistance": 500.0, "light": 0, "transparent": true, "solid": false, "textures": { "all": "block/water_still" }, "translucent": true, "fluid": true },
"lava": { "id": 16, "hardness": 100.0, "resistance": 500.0, "light": 15, "transparent": true, "solid": false, "textures": { "all": "block/lava_still" }, "translucent": true, "fluid": true },

View File

@@ -9,10 +9,15 @@ package main
import (
"context"
"flag"
"fmt"
"image"
"image/png"
"math"
"os"
"os/signal"
"sort"
"sync"
"sync/atomic"
"time"
"mc/internal/assets"
@@ -23,6 +28,7 @@ import (
"mc/internal/config"
"mc/internal/crash"
"mc/internal/engine"
"mc/internal/entity"
"mc/internal/font"
"mc/internal/game"
"mc/internal/inventory"
@@ -45,8 +51,14 @@ var devMode = flag.Bool("dev", devDefault, "开发模式:直接读取 assets/
// 开发测试参数(dev 测试通过后再打包)
var (
autoEnter = flag.Bool("auto", false, "自动进入游戏(跳过主菜单点击)")
shotPath = flag.String("shot", "", "截图路径:运行约 2 秒后保存 PNG 并退出(dev 测试用)")
autoEnter = flag.Bool("auto", false, "自动进入游戏(跳过主菜单点击)")
shotPath = flag.String("shot", "", "截图路径:运行约 2 秒后保存 PNG 并退出(dev 测试用)")
lookDeg = flag.Float64("lookdeg", 0, "dev 测试:进游戏后注入一次合成鼠标移动(角度),验证视角跟随链路")
pitchDeg = flag.Float64("pitchdeg", 0, "dev 测试:进游戏后把俯仰角直接设置为该值(度)")
giveItem = flag.String("give", "", "dev 测试:启动时给热键栏 0 号槽放入 64 个该物品(如 oak_planks/torch)")
crackStage = flag.Int("crackstage", -1, "dev 测试:截图前把破坏进度视觉设置为该阶段(0–9)")
spawnTest = flag.String("spawntest", "", "dev 测试:截图前在视线前方 5 格生成一个实体(zombie/animal/villager)")
escTest = flag.Bool("esctest", false, "dev 测试:截图前注入一次 ESC(验证暂停菜单与光标释放链路)")
)
// clientApp 客户端应用:把会话与渲染粘合起来(engine.Ticker + engine.Renderer)。
@@ -72,14 +84,32 @@ type clientApp struct {
remotePlayers map[uint32][3]float64
meshBuilder *mesh.Builder
uv func(string) (u0, v0, u1, v1 float32, ok bool)
mu sync.Mutex
pendingMesh []*worldMeshJob // tick 构建 → 渲染线程上传
// 光标模式请求(-1 无请求 / 0 释放 / 1 捕获):由 tick 写入、渲染线程消费。
// GLFW 调用必须发生在主线程(渲染线程),从 tick goroutine 直接调用
// SetInputMode 属线程违规——症状:ESC 后鼠标仍锁在窗口中心无法自由移动。
cursorReq atomic.Int32
// 手持方块(tick 计算 UV → 渲染线程上传,渲染.md §5.2)
handInit bool
handSlot int
handHas bool
handDirty bool
handUVs [6][4]float32
winW, winH int
tickCnt uint64
frames int // 渲染帧计数(截图时机用)
lastTime time.Time
lookInjected bool // dev 测试:-lookdeg 已注入
crackShot bool // dev 测试:-crackstage 已注入
spawnShot bool // dev 测试:-spawntest 已注入
escShot bool // dev 测试:-esctest 已注入
}
// worldMeshJob 待上传网格。
@@ -96,21 +126,21 @@ func (a *clientApp) Tick(dt float64) {
// dev 测试:-auto 自动进入游戏(跳过点击)
if *autoEnter {
a.screen = 1
a.rend.SetCursorMode(true)
a.requestCursor(true)
return
}
if a.in.clicked {
switch a.titleScreen.HitTest(a.in.clickX, a.in.clickY) {
case "play":
a.screen = 1
a.rend.SetCursorMode(true)
a.requestCursor(true)
case "multi":
// 联机:连接本机服务器(多人同步.md §2)
if err := dialServer(a, "127.0.0.1:25565", "玩家"); err != nil {
a.logf("连接服务器失败: %v", err)
} else {
a.screen = 1
a.rend.SetCursorMode(true)
a.requestCursor(true)
a.hud.OnlineCount = func() int {
a.remoteMu.Lock()
defer a.remoteMu.Unlock()
@@ -128,13 +158,13 @@ func (a *clientApp) Tick(dt float64) {
case 2: // 暂停
if a.in.escPressed {
a.screen = 1
a.rend.SetCursorMode(true)
a.requestCursor(true)
}
if a.in.clicked {
switch a.pauseScreen.HitTest(a.in.clickX, a.in.clickY) {
case "resume":
a.screen = 1
a.rend.SetCursorMode(true)
a.requestCursor(true)
case "quit":
a.rend.Window().SetShouldClose(true)
}
@@ -146,11 +176,28 @@ func (a *clientApp) Tick(dt float64) {
// 游戏中
if a.in.escPressed {
a.screen = 2
a.rend.SetCursorMode(false)
a.requestCursor(false)
a.in.escPressed = false
return
}
a.in.apply(a.sess)
// 手持方块 UV(选中槽变化时重算;渲染线程消费,渲染.md §5.2)
if !a.handInit || a.sess.Inv.Selected != a.handSlot {
a.computeHand()
}
// dev 测试:注入一次合成鼠标移动(走真实 cursorCallback,验证视角跟随链路)
if (*lookDeg != 0 || *pitchDeg != 0) && !a.lookInjected {
a.lookInjected = true
if *pitchDeg != 0 {
a.sess.Cam.Pitch = *pitchDeg * math.Pi / 180.0
}
if *lookDeg != 0 {
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)
}
}
a.sess.Tick(dt)
// 联机:上报移动(20Hz,多人同步.md §3)
@@ -207,8 +254,111 @@ func (a *clientApp) Tick(dt float64) {
}
}
// injectDevVisuals dev 测试注入(渲染线程,3D 绘制之前调用):
// -crackstage 设置破坏裂纹阶段(视线前方方块);-spawntest 生成实体;
// -esctest 注入 ESC(下一 tick 转暂停并请求释放光标)。
func (a *clientApp) injectDevVisuals() {
if *escTest && !a.escShot {
a.escShot = true
a.in.escPressed = true
a.logf("esctest 注入: ESC 按下(暂停菜单 + 光标释放链路)")
}
if *crackStage >= 0 && !a.crackShot {
a.crackShot = true
if h, ok := a.sess.Cast(4); ok {
a.sess.SetBreakVisual(h.X, h.Y, h.Z, float64(*crackStage)/10.0+0.05)
a.in.breakHeld = true // 防止 tick 的 EndBreak 清掉注入的裂纹视觉
a.logf("crackstage 注入: 方块(%d,%d,%d) 阶段 %d", h.X, h.Y, h.Z, *crackStage)
}
}
if *spawnTest != "" && !a.spawnShot {
a.spawnShot = true
f := a.sess.Cam.Forward()
ex, ez := int32(a.sess.Player.Pos[0]+f[0]*5), int32(a.sess.Player.Pos[2]+f[2]*5)
gy := int32(a.sess.Player.Pos[1])
for ; gy > 0; gy-- {
if a.sess.World.Block(ex, gy, ez) != block.Air {
break
}
}
ey := float64(gy + 1)
switch *spawnTest {
case "zombie":
a.sess.Entities.Spawn(entity.KindZombie, float64(ex)+0.5, ey, float64(ez)+0.5)
case "animal":
e := a.sess.Entities.Spawn(entity.KindAnimal, float64(ex)+0.5, ey, float64(ez)+0.5)
e.Data = &entity.Animal{}
case "villager":
a.sess.Entities.Spawn(entity.KindVillager, float64(ex)+0.5, ey, float64(ez)+0.5)
}
a.logf("spawntest 注入: %s 于 (%d,%d,%d)", *spawnTest, ex, gy+1, ez)
}
}
// requestCursor 请求切换光标捕获模式(由 tick 写入,渲染线程消费——
// GLFW 调用必须发生在渲染/主线程,UI与输入.md §6)。
func (a *clientApp) requestCursor(captured bool) {
if captured {
a.cursorReq.Store(1)
} else {
a.cursorReq.Store(0)
}
}
// computeHand 计算手持方块的六面 UV(选中槽的放置方块)。
// 顺序:顶/底/东/西/南/北(与 render.cubeFaces 一致)。
func (a *clientApp) computeHand() {
a.handInit = true
a.handSlot = a.sess.Inv.Selected
setEmpty := func() {
a.mu.Lock()
a.handHas = false
a.handDirty = true
a.mu.Unlock()
}
sel := a.sess.Inv.SelectedStack()
if sel.Empty() {
setEmpty()
return
}
d, ok := a.sess.Items.Get(sel.Name)
if !ok || d.Place == "" {
setEmpty()
return
}
bid, ok := a.reg.ID(d.Place)
if !ok {
setEmpty()
return
}
st := block.NewState(bid, 0)
faces := [6]string{"up", "down", "side", "side", "side", "side"}
var uvs [6][4]float32
for i, f := range faces {
u0, v0, u1, v1, found := a.uv(a.reg.Texture(st, f))
if !found {
u0, v0, u1, v1 = 0, 0, 1, 1
}
uvs[i] = [4]float32{u0, v0, u1, v1}
}
a.mu.Lock()
a.handUVs = uvs
a.handHas = true
a.handDirty = true
a.mu.Unlock()
}
// Render 每帧渲染(渲染线程):上传待传网格 → 视图矩阵 → 三 pass 绘制。
func (a *clientApp) Render() {
// 消费光标模式请求(主线程执行 GLFW 调用)
if req := a.cursorReq.Load(); req >= 0 {
a.rend.SetCursorMode(req == 1)
a.cursorReq.Store(-1)
}
// dev 测试:截图前注入破坏裂纹/实体/ESC(必须在 3D 绘制之前,截图帧才能拍到)
if a.frames == 195 && (*crackStage >= 0 || *spawnTest != "" || *escTest) {
a.injectDevVisuals()
}
// 上传 tick 构建好的网格(GL 调用必须在渲染线程)
a.mu.Lock()
pending := a.pendingMesh
@@ -228,6 +378,44 @@ func (a *clientApp) Render() {
a.rend.BeginFrame()
chunks := a.sess.World.ActiveChunks()
a.rend.DrawChunks(func(i int) (int32, int32) { return chunks[i].CX, chunks[i].CZ }, len(chunks))
// 实体广告牌(渲染.md §5.3:僵尸/村民/动物)
for _, e := range a.sess.Entities.List() {
if e.Dead {
continue
}
key, h := entityVisual(e)
if key == "" {
continue
}
if uv, ok := a.rend.EntityUV(key); ok {
a.rend.DrawBillboard(float32(e.Pos[0]), float32(e.Pos[1]), float32(e.Pos[2]), h, h*0.6, uv)
}
}
// 破坏裂纹叠加(方块交互与动画.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))
}
// 手持方块上传与绘制(渲染.md §5.2;上传必须是渲染线程)
a.mu.Lock()
handDirty, handHas := a.handDirty, a.handHas
a.handDirty = false
var handUVs [6][4]float32
if handHas {
handUVs = a.handUVs
}
a.mu.Unlock()
if handDirty {
if handHas {
a.rend.SetHandCube(handUVs, [3]float32{0.46, -0.44, -0.66}, 0.16)
} else {
a.rend.ClearHand()
}
}
a.rend.DrawHand()
// UI pass(UI与输入.md §2:3D 之后、状态隔离)
a.uiR.Begin()
mx, my := float32(a.in.cursorX), float32(a.in.cursorY)
@@ -263,6 +451,7 @@ func (a *clientApp) Render() {
if !ready {
a.logf("截图兜底:视野未完全就绪(frame=%d)", a.frames)
}
a.logf("shot cam: pos=%v yaw=%.2f pitch=%.2f chunks=%d", a.sess.Cam.Pos, a.sess.Cam.Yaw, a.sess.Cam.Pitch, len(a.sess.World.ActiveChunks()))
if err := a.saveScreenshot(*shotPath); err != nil {
a.logf("截图失败: %v", err)
} else {
@@ -360,6 +549,37 @@ func main() {
panic(err)
}
// 植被染色(渲染.md §4.1):原版材质包草顶/树叶为灰度图,
// 绿色来自染色表——图集构建后对带 tint 标记的纹理乘色,否则草地/树叶全灰。
if tinted := reg.TintTextureKeys(); len(tinted) > 0 {
atlasImg = applyAtlasTint(atlasImg, uvRects, am, tinted)
log.Infof("图集植被染色:%v", tinted)
}
// 裂纹图集(方块交互与动画.md §4:destroy_stage_0..9,破坏进度叠加用)
crackKeys := make([]string, 10)
for i := range crackKeys {
crackKeys[i] = fmt.Sprintf("block/destroy_stage_%d", i)
}
crackImg, crackRectMap, err := atlas.Build(am.ResolveTexture, crackKeys, 16)
if err != nil {
log.Warnf("裂纹图集构建失败:%v", err)
}
// 实体贴图图集(渲染.md §5.3:僵尸/村民/动物的广告牌贴图)
entityKeys := []string{
"entity/zombie/zombie",
"entity/villager/villager",
"entity/cow/cow_temperate",
"entity/pig/pig_temperate",
"entity/sheep/sheep",
"entity/chicken/chicken_temperate",
}
entityImg, entityRectMap, err := atlas.Build(am.ResolveTexture, entityKeys, 64)
if err != nil {
log.Warnf("实体图集构建失败:%v", err)
}
// 3) 世界与生成器(区块管理.md §4 异步流水线)
gen, err := worldgen.New(reg, 123456789, 0.005, 0.01, 0.1)
if err != nil {
@@ -399,6 +619,14 @@ func main() {
p := physics.NewPlayer(8.5, float64(surf+2), 8.5)
cam := camera.New(p.Eye(), 0, -0.35, cfg.Client.FOV) // 略微俯视,视野内以地形为主
inv := inventory.New(items)
// dev 测试:给 0 号槽物品(验证手持方块渲染)
if *giveItem != "" {
if inv.Add(item.Stack{Name: *giveItem, Count: 64}).Count != 0 {
log.Warnf("give 失败(物品不存在或已满): %s", *giveItem)
} else {
log.Infof("dev give: %s ×64 → 热键栏 0 号槽", *giveItem)
}
}
sess := game.NewSession(w, reg, items, recipes, p, inv, cam)
// 5) 渲染器(GL 线程亲和在其内部锁定)
@@ -410,6 +638,27 @@ func main() {
rend.SetAtlas(atlasImg)
// 主菜单状态:鼠标可见(仅游戏内捕获,UI与输入.md §6)
rend.SetCursorMode(false)
// 裂纹图集上传(方块交互与动画.md §4)
if crackImg != nil {
var crackRects [10]image.Rectangle
for i := 0; i < 10; i++ {
crackRects[i] = crackRectMap[crackKeys[i]]
}
rend.SetCrackAtlas(crackImg, crackRects)
}
// 实体贴图上传(渲染.md §5.3)
if entityImg != nil {
entityUV := make(map[string][4]float32, len(entityKeys))
W, H := float32(entityImg.Bounds().Dx()), float32(entityImg.Bounds().Dy())
for _, k := range entityKeys {
rc := entityRectMap[k]
entityUV[k] = [4]float32{
float32(rc.Min.X) / W, float32(rc.Min.Y) / H,
float32(rc.Max.X) / W, float32(rc.Max.Y) / H,
}
}
rend.SetEntityAtlas(entityImg, entityUV)
}
// 光影包(渲染.md §9:未安装 → 默认光影;坏包 → 回退默认)
if sp, err := render.LoadShaderPack(am); err != nil {
@@ -444,6 +693,33 @@ func main() {
}
hud.SetItemAtlas(itemTex, itemUV)
}
// 原版 HUD 精灵(UI还原.md §3.4:gui/sprites/hud/*)
loadTex := func(key string) uint32 {
p, ok := am.ResolveTexture(key)
if !ok {
return 0
}
f, err := os.Open(p)
if err != nil {
return 0
}
img, err := png.Decode(f)
_ = f.Close()
if err != nil {
return 0
}
return uiR.UploadImage(img)
}
hud.SetSprites(
loadTex("gui/sprites/hud/crosshair"),
loadTex("gui/sprites/hud/hotbar"),
loadTex("gui/sprites/hud/hotbar_selection"),
loadTex("gui/sprites/hud/heart/full"),
loadTex("gui/sprites/hud/heart/half"),
loadTex("gui/sprites/hud/heart/container"),
loadTex("gui/sprites/hud/food_full"),
loadTex("gui/sprites/hud/food_half"),
)
// 界面屏幕(原版复刻:土质背景/原版按钮/中文标题,UI还原.md §2)
lang, err := am.LoadLang(cfg.Client.Language)
@@ -467,6 +743,8 @@ func main() {
log: log,
remotePlayers: make(map[uint32][3]float64),
meshBuilder: mesh.NewBuilder(reg, uvFn),
uv: uvFn,
handSlot: -1,
winW: cfg.Client.WindowWidth,
winH: cfg.Client.WindowHeight,
lastTime: time.Now(),

30
cmd/client/entities_gl.go Normal file
View File

@@ -0,0 +1,30 @@
//go:build gl
// 实体可视映射:实体类型 → 广告牌贴图键与高度(渲染.md §5.3)。
package main
import "mc/internal/entity"
// entityVisual 返回实体的贴图键与身高(米)。
// 动物按 ID 轮换四种外观(cow/pig/sheep/chicken)。
func entityVisual(e *entity.Entity) (key string, height float32) {
switch e.Kind {
case entity.KindZombie:
return "entity/zombie/zombie", 1.95
case entity.KindVillager:
return "entity/villager/villager", 1.95
case entity.KindAnimal:
switch e.ID % 4 {
case 0:
return "entity/cow/cow_temperate", 1.4
case 1:
return "entity/pig/pig_temperate", 0.9
case 2:
return "entity/sheep/sheep", 1.3
default:
return "entity/chicken/chicken_temperate", 0.7
}
default:
return "", 0 // 掉落物等后置接入
}
}

View File

@@ -109,12 +109,6 @@ func (in *inputState) cursorCallback(_ *glfw.Window, x, y float64) {
const sens = 0.0025
in.dyaw += dx * sens
in.dpitch -= dy * sens
if in.dpitch > math.Pi/2-0.01 {
in.dpitch = math.Pi/2 - 0.01
}
if in.dpitch < -math.Pi/2+0.01 {
in.dpitch = -math.Pi/2 + 0.01
}
}
// scrollCallback 滚轮切热键栏(UI与输入.md §6)。
@@ -131,12 +125,21 @@ func (in *inputState) apply(s *game.Session) {
cam := s.Cam
p := s.Player
// 视角
// 视角(俯仰钳制在相机上,而非增量上——旧实现钳增量,抬头可翻转超过 ±90°)
if in.dyaw != 0 || in.dpitch != 0 {
cam.Yaw += in.dyaw
cam.Pitch += in.dpitch
in.dyaw, in.dpitch = 0, 0
const maxPitch = math.Pi/2 - 0.01
if cam.Pitch > maxPitch {
cam.Pitch = maxPitch
}
if cam.Pitch < -maxPitch {
cam.Pitch = -maxPitch
}
}
// 游戏中左键=破坏,不是 UI 点击:清除遗留点击状态,防止暂停菜单收到过期点击
in.clicked = false
// 水平移动(物理与碰撞.md §3:走/冲刺/潜行)
f := cam.Forward()

View File

@@ -24,10 +24,17 @@ var cjkPaths = []string{
}
// loadCJK 加载系统 CJK 字体(失败返回 nil → 中文标签退化不显示)。
func loadCJK() *cjkfont.Renderer {
// 按字号缓存:不同字号(标题 96 / splash 24 / 按钮 18)需要各自的光栅化实例。
var cjkCache = map[float64]*cjkfont.Renderer{}
func loadCJK(size float64) *cjkfont.Renderer {
if r, ok := cjkCache[size]; ok {
return r
}
for _, p := range cjkPaths {
if _, err := os.Stat(p); err == nil {
if r, err := cjkfont.Load(p, 24); err == nil {
if r, err := cjkfont.Load(p, size); err == nil {
cjkCache[size] = r
return r
}
}
@@ -37,8 +44,6 @@ func loadCJK() *cjkfont.Renderer {
// buildScreens 构建主菜单与暂停菜单(原版布局与控件)。
func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.TitleScreen, *ui.PauseScreen) {
cjk := loadCJK()
// 原版按钮控件(widget/button.png 200×20 与悬停高亮)
loadPng := func(key string) (uint32, bool) {
p, ok := am.ResolveTexture(key)
@@ -62,8 +67,9 @@ func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.Ti
// 土质背景:16px dirt 平铺 1280×720(原版菜单背景质感)
bgTex := tileBackground(r, am)
// 中文文本光栅化
// 中文文本光栅化(按请求字号取缓存渲染器)
text := func(s string, size float64, c color.RGBA) (uint32, float32, float32) {
cjk := loadCJK(size)
if cjk == nil || s == "" {
return 0, 0, 0
}
@@ -94,6 +100,13 @@ func buildScreens(r *ui.Renderer, am *assets.Manager, lang *assets.Lang) (*ui.Ti
Version: "年糕历险记 v0.1.0",
Copyright: "Copyright NianGao Studio. 请勿分发!",
}
// 版本/版权角标:中文走 CJK 光栅(ASCII 位图字体缺中文字形)
if vTex, vw, vh := tr("menu.version", "年糕历险记 v0.1.0", 12, color.RGBA{220, 220, 220, 255}); vTex != 0 {
title.VersionTex, title.VersionW, title.VersionH = vTex, vw, vh
}
if cTex, cw, ch := text("Copyright NianGao Studio. 请勿分发!", 12, color.RGBA{220, 220, 220, 255}); cTex != 0 {
title.CopyrightTex, title.CopyrightW, title.CopyrightH = cTex, cw, ch
}
title.Buttons = []ui.Button{
mkBtn("menu.singleplayer", "单人游戏", "play", 390),
mkBtn("menu.multiplayer", "多人游戏", "multi", 414),

70
cmd/client/tint_gl.go Normal file
View File

@@ -0,0 +1,70 @@
//go:build gl
// 图集植被染色(渲染.md §4.1):
// 原版材质包的草顶/树叶纹理是灰度图,绿色来自生物群系染色表
// (textures/colormap/grass.png、foliage.png)。
// MVP:取染色表中心色(平原绿)对图集内对应纹理乘色;按群系逐顶点着色后置。
package main
import (
"image"
"image/color"
"image/draw"
"image/png"
"os"
"mc/internal/assets"
)
// applyAtlasTint 给图集内带染色标记的纹理乘上染色表中心色。
// 返回新图集图像(原地复制后修改);染色表缺失的键保持原样。
func applyAtlasTint(src image.Image, uv map[string]image.Rectangle, am *assets.Manager, tinted map[string][]string) image.Image {
b := src.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src)
for tintName, keys := range tinted {
tr, tg, tb, ok := colormapCenter(am, tintName)
if !ok {
continue
}
for _, key := range keys {
rect, found := uv[key]
if !found {
continue
}
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
pr, pg, pb, pa := src.At(x, y).RGBA()
dst.SetRGBA(x, y, color.RGBA{
R: uint8(uint32(uint8(pr>>8)) * tr / 255),
G: uint8(uint32(uint8(pg>>8)) * tg / 255),
B: uint8(uint32(uint8(pb>>8)) * tb / 255),
A: uint8(pa >> 8),
})
}
}
}
}
return dst
}
// colormapCenter 读取染色表中心像素(0–255)。
func colormapCenter(am *assets.Manager, name string) (r, g, b uint32, ok bool) {
p, found := am.ResolveTexture("colormap/" + name)
if !found {
return 0, 0, 0, false
}
f, err := os.Open(p)
if err != nil {
return 0, 0, 0, false
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
return 0, 0, 0, false
}
cb := img.Bounds()
r, g, b, _ = img.At(cb.Min.X+cb.Dx()/2, cb.Min.Y+cb.Dy()/2).RGBA()
return r >> 8, g >> 8, b >> 8, true
}

2
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/klauspost/compress v1.19.2
github.com/ojrac/opensimplex-go v1.0.2
github.com/yuin/gopher-lua v1.1.2
golang.org/x/image v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -19,7 +20,6 @@ require (
github.com/hajimehoshi/oto v0.7.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect

View File

@@ -42,6 +42,7 @@ type Def struct {
RequiredTier int `json:"required_tier"`
Drops string `json:"drops"`
Textures map[string]string `json:"textures"`
Tint string `json:"tint"` // 植被染色表:grass/foliage(渲染.md §4.1)
}
// Registry 方块注册表。
@@ -140,3 +141,23 @@ func (r *Registry) Texture(s State, face string) string {
}
return "block/" + d.Name
}
// TintTextureKeys 返回「染色表名 → 涉及纹理键」:图集构建后按染色表给这些
// 纹理乘色(渲染.md §4.1 植被染色)。无染色标记的方块不出现。
func (r *Registry) TintTextureKeys() map[string][]string {
out := make(map[string][]string)
for _, d := range r.byID {
if d.ID == 0 || d.Tint == "" {
continue
}
seen := make(map[string]struct{})
for _, k := range d.Textures {
if _, dup := seen[k]; dup {
continue
}
seen[k] = struct{}{}
out[d.Tint] = append(out[d.Tint], k)
}
}
return out
}

View File

@@ -129,6 +129,13 @@ func (s *Session) BreakProgress() float64 { return s.breakProg }
// BreakTarget 当前破坏目标(渲染选择框/裂纹用)。
func (s *Session) BreakTarget() raycast.Hit { return s.breakTarget }
// SetBreakVisual 直接设置破坏进度显示状态(客户端渲染层裂纹叠加/开发测试用;
// 不修改世界数据,不驱动 accumulateBreak)。
func (s *Session) SetBreakVisual(x, y, z int32, prog float64) {
s.breakTarget = raycast.Hit{X: x, Y: y, Z: z}
s.breakProg = prog
}
// breakSpeed 每秒破坏进度(方块交互与动画.md §3、挖矿与矿物.md §3 同公式)。
func (s *Session) breakSpeed(def block.Def, tool item.Stack) float64 {
speed := 1.0 // 空手

View File

@@ -0,0 +1,112 @@
//go:build gl
// 实体广告牌渲染(实体系统.md §5、渲染.md §5.3):
// 以相机朝向的四边形绘制实体贴图(zombie/villager/动物)。
// 复用 chunk 着色器:aPos 为单位四边形局部坐标(x∈[-0.5,0.5],y∈[0,1],
// y=0 为脚底),顶点着色器按 uCamRight/uCamUp 展开成世界坐标;
// UV 经 uUVMin/uUVMax 重映射到图集内该实体的格子。
package render
import (
"image"
"unsafe"
"mc/internal/mesh"
"github.com/go-gl/gl/v3.3-core/gl"
)
// SetEntityAtlas 上传实体贴图图集并记录 UV(键 → 归一化 UV)。
func (r *Renderer) SetEntityAtlas(img image.Image, uv map[string][4]float32) {
if r.entityTex == 0 {
gl.GenTextures(1, &r.entityTex)
}
gl.BindTexture(gl.TEXTURE_2D, r.entityTex)
b := img.Bounds()
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, int32(b.Dx()), int32(b.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(imageBytes(img)))
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
r.entityUV = uv
}
// EntityUV 返回实体贴图 UV(不存在返回 false)。
func (r *Renderer) EntityUV(key string) ([4]float32, bool) {
uv, ok := r.entityUV[key]
return uv, ok
}
// ensureBillQuad 惰性构建单位广告牌四边形(pos+uv+light 顶点格式,CW 绕序正面朝相机)。
// aUV.y=0 对应纹理底部(脚底),1 对应顶部(头)——顶点序按此翻转 v。
func (r *Renderer) ensureBillQuad() {
if r.billVao != 0 {
return
}
verts := []mesh.Vertex{
{X: -0.5, Y: 0, Z: 0, U: 0, V: 1, Sky: 15, Block: 15}, // 脚底左(uv v=1)
{X: -0.5, Y: 1, Z: 0, U: 0, V: 0, Sky: 15, Block: 15}, // 头左(uv v=0)
{X: 0.5, Y: 1, Z: 0, U: 1, V: 0, Sky: 15, Block: 15}, // 头右
{X: 0.5, Y: 0, Z: 0, U: 1, V: 1, Sky: 15, Block: 15}, // 脚底右
}
idx := []uint32{0, 3, 1, 3, 2, 1} // 窗口坐标下 CW(FrontFace=CW 时为正面向相机)
gl.GenVertexArrays(1, &r.billVao)
gl.GenBuffers(1, &r.billVbo)
gl.GenBuffers(1, &r.billIbo)
gl.BindVertexArray(r.billVao)
gl.BindBuffer(gl.ARRAY_BUFFER, r.billVbo)
gl.BufferData(gl.ARRAY_BUFFER, len(verts)*int(unsafe.Sizeof(mesh.Vertex{})), gl.Ptr(verts), gl.STATIC_DRAW)
gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, r.billIbo)
gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(idx)*4, gl.Ptr(idx), gl.STATIC_DRAW)
stride := int32(unsafe.Sizeof(mesh.Vertex{}))
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointer(0, 3, gl.FLOAT, false, stride, gl.PtrOffset(0))
gl.EnableVertexAttribArray(1)
gl.VertexAttribPointer(1, 2, gl.FLOAT, false, stride, gl.PtrOffset(3*4))
gl.EnableVertexAttribArray(2)
gl.VertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, stride, gl.PtrOffset(5*4))
gl.BindVertexArray(0)
}
// DrawBillboard 在 (x,y,z)(脚底)绘制 h 高 × w 宽的实体广告牌,贴图 uv。
func (r *Renderer) DrawBillboard(x, y, z float32, h, w float32, uv [4]float32) {
if r.entityTex == 0 {
return
}
r.ensureBillQuad()
gl.UseProgram(r.prog)
gl.ActiveTexture(gl.TEXTURE0)
gl.BindTexture(gl.TEXTURE_2D, r.entityTex)
vpLoc := gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00"))
var vp [16]float32
multiply4(&vp, &r.proj, &r.view)
gl.UniformMatrix4fv(vpLoc, 1, false, &vp[0])
offLoc := gl.GetUniformLocation(r.prog, gl.Str("uOffset\x00"))
gl.Uniform3f(offLoc, x, y, z)
cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00"))
gl.Uniform1f(cutLoc, 0)
// 相机基向量(视图矩阵列 0/1 即世界系下的 right/up)
rightLoc := gl.GetUniformLocation(r.prog, gl.Str("uCamRight\x00"))
upLoc := gl.GetUniformLocation(r.prog, gl.Str("uCamUp\x00"))
gl.Uniform3f(rightLoc, r.view[0], r.view[1], r.view[2])
gl.Uniform3f(upLoc, r.view[4], r.view[5], r.view[6])
billLoc := gl.GetUniformLocation(r.prog, gl.Str("uBill\x00"))
gl.Uniform2f(billLoc, w, h)
uvMinLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00"))
uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00"))
gl.Uniform2f(uvMinLoc, uv[0], uv[1])
gl.Uniform2f(uvMaxLoc, uv[2], uv[3])
gl.Disable(gl.CULL_FACE) // 广告牌永远朝向相机,无需剔除
gl.BindVertexArray(r.billVao)
gl.DrawElements(gl.TRIANGLES, 6, gl.UNSIGNED_INT, gl.PtrOffset(0))
gl.BindVertexArray(0)
gl.Enable(gl.CULL_FACE)
// 复位广告牌状态(避免影响后续裂纹/手持/UI)
gl.Uniform2f(billLoc, 1, 0) // uBill.y=0 → 顶点着色器走普通路径
gl.Uniform2f(uvMinLoc, 0, 0)
gl.Uniform2f(uvMaxLoc, 1, 1)
}

View File

@@ -102,6 +102,14 @@ func (r *Renderer) DrawChunks(get func(i int) (int32, int32), n int) {
vpLoc := gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00"))
cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00"))
gl.UniformMatrix4fv(vpLoc, 1, false, &vp[0])
offLoc := gl.GetUniformLocation(r.prog, gl.Str("uOffset\x00"))
gl.Uniform3f(offLoc, 0, 0, 0) // 区块网格无偏移(覆盖层在 DrawChunks 后自行设置)
billLoc := gl.GetUniformLocation(r.prog, gl.Str("uBill\x00"))
gl.Uniform2f(billLoc, 1, 0) // 非广告牌路径
uvMinLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00"))
uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00"))
gl.Uniform2f(uvMinLoc, 0, 0) // 区块顶点 UV 直接使用
gl.Uniform2f(uvMaxLoc, 1, 1)
// pass 1+2:不透明与 cutout(alpha test,不排序)
gl.Uniform1f(cutLoc, 0)

View File

@@ -0,0 +1,231 @@
//go:build gl
// 覆盖层渲染:破坏裂纹叠加(destroy_stage_0..9)与第一人称手持方块
// (方块交互与动画.md §4、渲染.md §5.2)。
// 与区块网格共用顶点格式(pos+uv+light)与着色器,通过 uOffset 平移定位。
package render
import (
"image"
"unsafe"
"mc/internal/mesh"
"github.com/go-gl/gl/v3.3-core/gl"
)
// cubeFaces 单位立方体 6 面角顺序(与 mesh.faceDef 一致,CW 绕序为正面)。
var cubeFaces = [6][4][3]float32{
{{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}, // 顶
{{0, 0, 1}, {1, 0, 1}, {1, 0, 0}, {0, 0, 0}}, // 底
{{1, 0, 0}, {1, 0, 1}, {1, 1, 1}, {1, 1, 0}}, // 东(+x)
{{0, 0, 1}, {0, 0, 0}, {0, 1, 0}, {0, 1, 1}}, // 西(-x)
{{1, 0, 1}, {0, 0, 1}, {0, 1, 1}, {1, 1, 1}}, // 南(+z)
{{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}}, // 北(-z)
}
// cubeVerts 按六面 UV 构建单位立方体顶点(mesh.Vertex 格式:pos+uv+light 字节,
// 光照满亮——覆盖层不受世界光照影响)。inflate 沿角外扩(裂纹盒防 z-fighting)。
func cubeVerts(uvs [6][4]float32, inflate float32) []mesh.Vertex {
var verts []mesh.Vertex
for fi, face := range cubeFaces {
u0, v0, u1, v1 := uvs[fi][0], uvs[fi][1], uvs[fi][2], uvs[fi][3]
for _, c := range face {
// 角 UV 权重(同 mesh.cornerUV)
var cu, cv float32
switch fi {
case 0, 1: // 顶/底:x→u,z→v
cu, cv = c[0], c[2]
case 2, 3: // 东/西:z→u,y→v(v 翻转)
cu, cv = c[2], 1-c[1]
default: // 南/北:x→u,y→v(v 翻转)
cu, cv = c[0], 1-c[1]
}
verts = append(verts, mesh.Vertex{
X: (c[0]-0.5)*(1+inflate) + 0.5,
Y: (c[1]-0.5)*(1+inflate) + 0.5,
Z: (c[2]-0.5)*(1+inflate) + 0.5,
U: u0 + (u1-u0)*cu, V: v0 + (v1-v0)*cv,
Sky: 15, Block: 15,
})
}
}
return verts
}
// cubeIndices 24 顶点 → 36 索引(每面 2 三角形)。
func cubeIndices() []uint32 {
idx := make([]uint32, 0, 36)
for base := 0; base < 24; base += 4 {
b := uint32(base)
idx = append(idx, b, b+1, b+2, b+2, b+3, b)
}
return idx
}
// uploadMesh 上传顶点/索引并绑定顶点属性(chunk 顶点布局,渲染.md §5)。
func uploadMesh(verts []mesh.Vertex, idx []uint32) (vao, vbo, ibo uint32) {
gl.GenVertexArrays(1, &vao)
gl.GenBuffers(1, &vbo)
gl.GenBuffers(1, &ibo)
gl.BindVertexArray(vao)
gl.BindBuffer(gl.ARRAY_BUFFER, vbo)
gl.BufferData(gl.ARRAY_BUFFER, len(verts)*int(unsafe.Sizeof(mesh.Vertex{})), gl.Ptr(verts), gl.STATIC_DRAW)
gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo)
gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(idx)*4, gl.Ptr(idx), gl.STATIC_DRAW)
stride := int32(unsafe.Sizeof(mesh.Vertex{}))
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointer(0, 3, gl.FLOAT, false, stride, gl.PtrOffset(0))
gl.EnableVertexAttribArray(1)
gl.VertexAttribPointer(1, 2, gl.FLOAT, false, stride, gl.PtrOffset(3*4))
gl.EnableVertexAttribArray(2)
gl.VertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, stride, gl.PtrOffset(5*4))
gl.BindVertexArray(0)
return
}
// buildCubeVAO 构建立方体 VAO(顶点格式与 chunk 一致:pos+uv+light)。
// uvs 六面 UV(顺序同 cubeFaces);inflate 沿角外扩;立方体锚点为原点,边长为 1。
func buildCubeVAO(uvs [6][4]float32, inflate float32) uint32 {
vao, _, _ := uploadMesh(cubeVerts(uvs, inflate), cubeIndices())
return vao
}
// SetCrackAtlas 上传裂纹图集(10 阶段 destroy_stage)并记录各阶段 UV。
// img 为调用方打包好的图集,rects[i] 为阶段 i 的像素矩形(图集坐标系)。
func (r *Renderer) SetCrackAtlas(img image.Image, rects [10]image.Rectangle) {
if r.crackTex == 0 {
gl.GenTextures(1, &r.crackTex)
}
gl.BindTexture(gl.TEXTURE_2D, r.crackTex)
b := img.Bounds()
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, int32(b.Dx()), int32(b.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(imageBytes(img)))
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
W, H := float32(b.Dx()), float32(b.Dy())
const inset = 0.5 // 防渗色(渲染.md §4)
for i, rc := range rects {
r.crackUVs[i] = [4]float32{
(float32(rc.Min.X) + inset) / W, (float32(rc.Min.Y) + inset) / H,
(float32(rc.Max.X) - inset) / W, (float32(rc.Max.Y) - inset) / H,
}
}
}
// DrawCrackOverlay 在目标方块 (x,y,z) 叠加 stage 阶段裂纹盒
// (方块交互与动画.md §4:0–9,六面同图,盒体略外扩防 z-fighting)。
func (r *Renderer) DrawCrackOverlay(x, y, z float32, stage int) {
if r.crackTex == 0 {
return
}
if stage < 0 {
stage = 0
}
if stage > 9 {
stage = 9
}
if r.crackCubes[stage] == 0 {
uv := r.crackUVs[stage]
r.crackCubes[stage] = buildCubeVAO([6][4]float32{uv, uv, uv, uv, uv, uv}, 0.004)
}
gl.UseProgram(r.prog)
gl.ActiveTexture(gl.TEXTURE0)
gl.BindTexture(gl.TEXTURE_2D, r.crackTex)
vpLoc := gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00"))
var vp [16]float32
multiply4(&vp, &r.proj, &r.view)
gl.UniformMatrix4fv(vpLoc, 1, false, &vp[0])
offLoc := gl.GetUniformLocation(r.prog, gl.Str("uOffset\x00"))
gl.Uniform3f(offLoc, x, y, z)
cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00"))
gl.Uniform1f(cutLoc, 0)
billLoc := gl.GetUniformLocation(r.prog, gl.Str("uBill\x00"))
gl.Uniform2f(billLoc, 1, 0) // 非广告牌路径
uvMinLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00"))
uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00"))
gl.Uniform2f(uvMinLoc, 0, 0)
gl.Uniform2f(uvMaxLoc, 1, 1)
gl.Enable(gl.POLYGON_OFFSET_FILL)
gl.PolygonOffset(-1, -1)
gl.BindVertexArray(r.crackCubes[stage])
gl.DrawElements(gl.TRIANGLES, 36, gl.UNSIGNED_INT, gl.PtrOffset(0))
gl.BindVertexArray(0)
gl.Disable(gl.POLYGON_OFFSET_FILL)
gl.Uniform3f(offLoc, 0, 0, 0) // 复位平移(后续绘制依赖 0 偏移)
}
// SetHandCube 上传/更新手持方块立方体(六面 UV 顺序同 cubeFaces)。
// pos/scale 为视图空间下的立方体中心与边长(渲染.md §5.2)。
func (r *Renderer) SetHandCube(uvs [6][4]float32, pos [3]float32, scale float32) {
r.ClearHand()
// 直接按视图空间坐标构建顶点(顶点含 pos 偏移)
var verts []mesh.Vertex
for fi, face := range cubeFaces {
u0, v0, u1, v1 := uvs[fi][0], uvs[fi][1], uvs[fi][2], uvs[fi][3]
for _, c := range face {
var cu, cv float32
switch fi {
case 0, 1:
cu, cv = c[0], c[2]
case 2, 3:
cu, cv = c[2], 1-c[1]
default:
cu, cv = c[0], 1-c[1]
}
verts = append(verts, mesh.Vertex{
X: pos[0] + (c[0]-0.5)*scale,
Y: pos[1] + (c[1]-0.5)*scale,
Z: pos[2] + (c[2]-0.5)*scale,
U: u0 + (u1-u0)*cu, V: v0 + (v1-v0)*cv,
Sky: 15, Block: 15,
})
}
}
r.handVao, r.handVbo, r.handIbo = uploadMesh(verts, cubeIndices())
}
// ClearHand 释放手持方块网格(手持物品为空时调用)。
func (r *Renderer) ClearHand() {
if r.handVao != 0 {
gl.DeleteVertexArrays(1, &r.handVao)
r.handVao = 0
}
if r.handVbo != 0 {
gl.DeleteBuffers(1, &r.handVbo)
r.handVbo = 0
}
if r.handIbo != 0 {
gl.DeleteBuffers(1, &r.handIbo)
r.handIbo = 0
}
}
// DrawHand 绘制手持方块(顶点已是视图空间,仅乘投影矩阵)。
func (r *Renderer) DrawHand() {
if r.handVao == 0 {
return
}
gl.UseProgram(r.prog)
gl.ActiveTexture(gl.TEXTURE0)
gl.BindTexture(gl.TEXTURE_2D, r.atlas)
vpLoc := gl.GetUniformLocation(r.prog, gl.Str("uViewProj\x00"))
gl.UniformMatrix4fv(vpLoc, 1, false, &r.proj[0])
offLoc := gl.GetUniformLocation(r.prog, gl.Str("uOffset\x00"))
gl.Uniform3f(offLoc, 0, 0, 0)
cutLoc := gl.GetUniformLocation(r.prog, gl.Str("uCutout\x00"))
gl.Uniform1f(cutLoc, 0)
billLoc := gl.GetUniformLocation(r.prog, gl.Str("uBill\x00"))
gl.Uniform2f(billLoc, 1, 0)
uvMinLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMin\x00"))
uvMaxLoc := gl.GetUniformLocation(r.prog, gl.Str("uUVMax\x00"))
gl.Uniform2f(uvMinLoc, 0, 0)
gl.Uniform2f(uvMaxLoc, 1, 1)
gl.BindVertexArray(r.handVao)
gl.DrawElements(gl.TRIANGLES, 36, gl.UNSIGNED_INT, gl.PtrOffset(0))
gl.BindVertexArray(0)
}

View File

@@ -27,6 +27,19 @@ type Renderer struct {
// 区块网格缓存:key → VAO/VBO/索引数
chunks map[uint64]*chunkGL
// 裂纹覆盖层(方块交互与动画.md §4)
crackTex uint32
crackUVs [10][4]float32
crackCubes [10]uint32
// 第一人称手持方块(渲染.md §5.2)
handVao, handVbo, handIbo uint32
// 实体广告牌(渲染.md §5.3)
entityTex uint32
entityUV map[string][4]float32
billVao, billVbo, billIbo uint32
proj [16]float32
view [16]float32
}
@@ -217,11 +230,21 @@ layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aUV;
layout(location = 2) in vec2 aLight; // x=天空光 y=方块光(4-bit)
uniform mat4 uViewProj;
uniform vec3 uOffset; // 覆盖层/广告牌锚点平移(区块绘制时为 0)
uniform vec3 uCamRight; // 相机右向量(世界系,广告牌展开用)
uniform vec3 uCamUp; // 相机上向量(世界系)
uniform vec2 uBill; // 广告牌缩放:x=右 y=上;y=0 时走普通路径
uniform vec2 uUVMin; // UV 重映射下限(区块/普通立方体为 0,0)
uniform vec2 uUVMax; // UV 重映射上限(区块/普通立方体为 1,1)
out vec2 vUV;
out vec2 vLight;
void main() {
gl_Position = uViewProj * vec4(aPos, 1.0);
vUV = aUV;
vec3 pos = aPos + uOffset;
if (uBill.y > 0.001) {
pos = uOffset + vec3(aPos.x * uCamRight * uBill.x + aPos.y * uCamUp * uBill.y);
}
gl_Position = uViewProj * vec4(pos, 1.0);
vUV = uUVMin + aUV * (uUVMax - uUVMin);
vLight = aLight;
}
`

View File

@@ -19,6 +19,17 @@ type HUD struct {
itemTex uint32
itemUV map[string][4]float32
// 原版 HUD 精灵(UI还原.md §3.4:gui/sprites/hud/*)
crossTex uint32 // crosshair.png 15×15
hotbarTex uint32 // hotbar.png 182×22
hotbarSel uint32 // hotbar_selection.png 24×24
heartFull uint32 // heart/full.png 9×9
heartHalf uint32 // heart/half.png
heartCont uint32 // heart/container.png
foodFull uint32 // food_full.png 9×9
foodHalf uint32 // food_half.png
haveSprites bool
// 逻辑分辨率与缩放(UI还原.md §3.1:427×240 基准 × 整数缩放)
baseW, baseH float32
scale float32
@@ -38,61 +49,119 @@ func (h *HUD) SetItemAtlas(tex uint32, uv map[string][4]float32) {
h.itemUV = 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
h.heartFull, h.heartHalf, h.heartCont = heartFull, heartHalf, heartCont
h.foodFull, h.foodHalf = foodFull, foodHalf
h.haveSprites = cross != 0
}
// Draw 每帧绘制 HUD(调用方已 Begin)。
func (h *HUD) Draw(r *Renderer) {
w, hgt := float32(r.w), float32(r.h)
s := h.scale
white := [4]float32{1, 1, 1, 1}
// 准星(UI还原.md §3.4:屏幕正中 15×15 十字)
// 准星(原版 crosshair.png 15×15,UI还原.md §3.4)
cx, cy := w/2, hgt/2
r.DrawRect(cx-1*s, cy-8*s, 2*s, 16*s, white)
r.DrawRect(cx-8*s, cy-1*s, 16*s, 2*s, white)
if h.haveSprites {
h.blit(r, h.crossTex, cx-7.5*s, cy-7.5*s, 15*s, 15*s)
} else {
white := [4]float32{1, 1, 1, 1}
r.DrawRect(cx-1*s, cy-8*s, 2*s, 16*s, white)
r.DrawRect(cx-8*s, cy-1*s, 16*s, 2*s, white)
}
// 热键栏(UI还原.md §3.4:182×22,底部居中)
// 热键栏(原版 hotbar.png 182×22,底部居中,UI还原.md §3.4)
barW, barH := 182*s, 22*s
bx, by := cx-barW/2, hgt-barH
r.DrawRect(bx, by, barW, barH, [4]float32{0, 0, 0, 0.5})
if h.haveSprites {
h.blit(r, h.hotbarTex, bx, by, barW, barH)
} else {
r.DrawRect(bx, by, barW, barH, [4]float32{0, 0, 0, 0.5})
}
for i := 0; i < 9; i++ {
sx := bx + float32(i)*20*s + 1*s
sy := by + 3*s
if i == h.sess.Inv.Selected {
r.DrawRect(sx-2*s, sy-2*s, 20*s, 20*s, [4]float32{1, 1, 1, 0.9}) // 选中高亮框
if h.haveSprites {
h.blit(r, h.hotbarSel, bx+float32(i)*20*s-2*s, by-1*s, 24*s, 24*s)
} else {
r.DrawRect(bx+float32(i)*20*s+1*s, by+3*s, 20*s, 20*s, [4]float32{1, 1, 1, 0.9})
}
}
h.drawItemIcon(r, h.sess.Inv.Slots[i], sx, sy)
h.drawItemIcon(r, h.sess.Inv.Slots[i], bx+float32(i)*20*s+3*s, by+3*s)
}
// 生命:10 心(每心 9×9,屏幕左下,UI还原.md §3.4)
hearts := int(20 / 2)
// 生命:10 心(原版 9×9 图标,屏幕左下,UI还原.md §3.4 布局:
// 饥饿行 y=h-39,生命行在其上 y=h-49)
hp := h.sess.PlayerHealth()
hx, hy := w/2-91*s, hgt-30*s
for i := 0; i < hearts; i++ {
color := [4]float32{0.2, 0.2, 0.2, 0.6} // 空
if i < hp/2 {
color = [4]float32{1, 0.1, 0.1, 1} // 满
}
r.DrawRect(hx+float32(i%10)*8*s, hy+float32(i/10)*9*s, 7*s, 7*s, color)
}
// 饥饿:10 鸡腿(右侧偏移)
hx := w/2 - 91*s
hy := hgt - 49*s
for i := 0; i < 10; i++ {
r.DrawRect(w/2-91*s+100*s+float32(i%10)*8*s, hy+float32(i/10)*9*s, 7*s, 7*s, [4]float32{0.6, 0.4, 0.15, 1})
x := hx + float32(i)*8*s
// 底:容器(空槽)
if h.haveSprites {
h.blit(r, h.heartCont, x, hy, 9*s, 9*s)
}
switch {
case hp >= (i+1)*2: // 满心
if h.haveSprites {
h.blit(r, h.heartFull, x, hy, 9*s, 9*s)
} else {
r.DrawRect(x, hy, 7*s, 7*s, [4]float32{1, 0.1, 0.1, 1})
}
case hp == i*2+1: // 半心
if h.haveSprites {
h.blit(r, h.heartHalf, x, hy, 9*s, 9*s)
} else {
r.DrawRect(x, hy, 3.5*s, 7*s, [4]float32{1, 0.1, 0.1, 1})
}
default: // 空
if !h.haveSprites {
r.DrawRect(x, hy, 7*s, 7*s, [4]float32{0.2, 0.2, 0.2, 0.6})
}
}
}
// 破坏进度(方块交互与动画.md §4:HUD 中央进度)
if prog := h.sess.BreakProgress(); prog > 0 {
r.DrawRect(cx-40*s, cy+20*s, 80*s, 4*s, [4]float32{0, 0, 0, 0.7})
r.DrawRect(cx-40*s, cy+20*s, 80*s*float32(prog), 4*s, [4]float32{1, 1, 1, 0.9})
// 饥饿:10 鸡腿(原版 food_full,生命行下方)
food := 20 // 饥饿值(饥饿系统后置,恒定 20)
fy := hgt - 39*s
for i := 0; i < 10; i++ {
x := hx + float32(i)*8*s
switch {
case food >= (i+1)*2:
if h.haveSprites {
h.blit(r, h.foodFull, x, fy, 9*s, 9*s)
} else {
r.DrawRect(x, fy, 7*s, 7*s, [4]float32{0.6, 0.4, 0.15, 1})
}
case food == i*2+1:
if h.haveSprites {
h.blit(r, h.foodHalf, x, fy, 9*s, 9*s)
} else {
r.DrawRect(x, fy, 3.5*s, 7*s, [4]float32{0.6, 0.4, 0.15, 1})
}
}
}
// 破坏进度(方块交互与动画.md §4:3D 裂纹由渲染层叠加,HUD 不画进度条)
// 坐标调试(后置移除)
r.DrawText(w/2, hgt-40*s, fmt.Sprintf("XYZ %.1f / %.1f / %.1f", h.sess.Player.Pos[0], h.sess.Player.Pos[1], h.sess.Player.Pos[2]), 1*s, white)
r.DrawText(w/2, hgt-70*s, fmt.Sprintf("XYZ %.1f / %.1f / %.1f", h.sess.Player.Pos[0], h.sess.Player.Pos[1], h.sess.Player.Pos[2]), 1*s, [4]float32{1, 1, 1, 1})
// 联机在线人数(多人同步.md §3)
if h.OnlineCount != nil {
r.DrawText(w/2, hgt-56*s, fmt.Sprintf("在线玩家: %d", h.OnlineCount()), 1*s, white)
r.DrawText(w/2, hgt-82*s, fmt.Sprintf("在线玩家: %d", h.OnlineCount()), 1*s, [4]float32{1, 1, 1, 1})
}
}
// blit 以纹理原比例绘制一个四边形(UI 精灵)。
func (h *HUD) blit(r *Renderer, tex uint32, x, y, w, hgt float32) {
if tex == 0 {
return
}
r.pushQuad(quad{x: x, y: y, w: w, h: hgt, u0: 0, v0: 0, u1: 1, v1: 1,
r: 1, g: 1, b: 1, a: 1, tex: tex})
}
// drawItemIcon 在槽位绘制物品图标 + 数量。
func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) {
s := h.scale
@@ -104,7 +173,7 @@ func (h *HUD) drawItemIcon(r *Renderer, stack item.Stack, x, y float32) {
return
}
if uv, found := h.itemUV[def.Texture]; found && h.itemTex != 0 {
r.pushQuad(quad{x: x + 2*s, y: y + 2*s, w: 16 * s, h: 16 * s,
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})
}

View File

@@ -27,8 +27,14 @@ type TitleScreen struct {
BtnHover uint32 // 悬停高亮(button_highlighted.png)
Buttons []Button
Version string // 左下角版本(原版角标)
Copyright string // 右下角版权
Version string // 左下角版本(原版角标)
Copyright string // 右下角版权
VersionTex uint32 // 版本/版权 CJK 光栅纹理(中文字形)
VersionW float32
VersionH float32
CopyrightTex uint32
CopyrightW float32
CopyrightH float32
}
// HitTest 命中检测(按钮区域)。
@@ -78,11 +84,17 @@ func (s *TitleScreen) Draw(r *Renderer, mx, my float32) {
u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: b.Tex})
}
}
// 版本角标(左下)与版权(右下,原版样式)
if s.Version != "" {
// 版本角标(左下)与版权(右下,原版样式;中文用预光栅纹理)
if s.VersionTex != 0 {
r.pushQuad(quad{x: 4, y: h - s.VersionH - 4, w: s.VersionW, h: s.VersionH,
u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.VersionTex})
} else if s.Version != "" {
r.DrawText(8, h-12, s.Version, 1, [4]float32{1, 1, 1, 1})
}
if s.Copyright != "" {
if s.CopyrightTex != 0 {
r.pushQuad(quad{x: w - s.CopyrightW - 4, y: h - s.CopyrightH - 4, w: s.CopyrightW, h: s.CopyrightH,
u0: 0, v0: 0, u1: 1, v1: 1, r: 1, g: 1, b: 1, a: 1, tex: s.CopyrightTex})
} else if s.Copyright != "" {
r.DrawText(w-r.TextWidth(s.Copyright, 1)-8, h-12, s.Copyright, 1, [4]float32{1, 1, 1, 1})
}
}