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

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
}