Files
ngzz-mc/internal/game/game.go
NianGao Dev 768a9b8096 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 全链路截图验证
2026-08-16 10:23:01 +08:00

498 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package game 游戏会话:输入 → 玩家 → 世界 → 交互(破坏/放置)的粘合层(架构.md §3 主循环)。
//
// 职责边界:会话只做调度与玩法逻辑,渲染/UI/网络通过接口或外部驱动访问。
package game
import (
"math"
"math/rand"
"time"
"mc/internal/block"
"mc/internal/blockentity"
"mc/internal/camera"
"mc/internal/entity"
"mc/internal/inventory"
"mc/internal/item"
"mc/internal/physics"
"mc/internal/raycast"
"mc/internal/recipe"
"mc/internal/world"
)
// Session 单机游戏会话。
type Session struct {
World *world.World
Reg *block.Registry
Items *item.Registry
Recipes *recipe.Registry
Player *physics.Player
Inv *inventory.Inventory
Cam *camera.Camera
// 实体与方块实体(实体系统.md、物品与背包.md §6)
Entities *entity.Manager
BlockEntities map[[3]int32]*blockentity.Entity
// 自然生成计时(僵尸与敌对生物.md §5、动物体系.md §6)
spawnTimer float64
animalTimer float64
damageIn float64 // 玩家受击冷却
rng *rand.Rand
// 破坏进度状态(方块交互与动画.md §3)
breakTarget raycast.Hit
breaking bool
breakProg float64
// 玩家生命(僵尸/摔落伤害后置接入,实体系统.md §6)
Health int
// 创造模式(跳过进度直接破坏)
Creative bool
// 工作台状态(工作台与合成表.md §5:3×3 网格 + 输出格)
craftOpen bool
craftGrid [9]item.Stack
}
// NewSession 创建会话。
func NewSession(w *world.World, reg *block.Registry, items *item.Registry, recipes *recipe.Registry, p *physics.Player, inv *inventory.Inventory, cam *camera.Camera) *Session {
return &Session{
World: w, Reg: reg, Items: items, Recipes: recipes, Player: p, Inv: inv, Cam: cam,
Health: 20, Entities: entity.NewManager(),
BlockEntities: make(map[[3]int32]*blockentity.Entity),
rng: rand.New(rand.NewSource(12345)),
}
}
// PlayerHealth 玩家当前生命(HUD 用)。
func (s *Session) PlayerHealth() int { return s.Health }
// Tick 每 tick(20Hz)会话逻辑(架构.md §3.2)。
func (s *Session) Tick(dt float64) {
// 物理积分(速度由输入层写入 Player.Vel)
s.Player.Tick(s.World, dt)
// 区块调度(主线程预算 8ms,区块管理.md §4.3)
s.World.Update(s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2], 8*time.Millisecond)
// 破坏进度累积
if s.breaking {
s.accumulateBreak(dt)
}
// 实体 AI(实体系统.md §3 限频决策在内部处理)
s.Entities.Tick(dt, s.World, s.Player.Pos)
// 方块实体(熔炉烧炼,物品与背包.md §6)
s.tickBlockEntities()
// 掉落物拾取(实体系统.md §5)
for _, d := range s.Entities.PickupNearby(s.Player.Pos[0], s.Player.Pos[1], s.Player.Pos[2]) {
s.Inv.Add(item.Stack{Name: d.Item, Count: uint8(min(d.Count, 127))})
}
// 夜间刷僵尸(僵尸与敌对生物.md §5:黑暗处、距玩家 24 格、限频)
s.spawnZombies(dt)
// 白天刷动物(动物体系.md §6:草面、亮度≥9、距玩家 16–32、限频)
s.spawnAnimals(dt)
// 僵尸近身伤害(僵尸与敌对生物.md §3:3 点/秒)
s.applyZombieDamage(dt)
// 相机跟随玩家眼睛
s.Cam.Pos = s.Player.Eye()
}
// Cast 从相机射线拾取(方块交互与动画.md §2)。
func (s *Session) Cast(maxDist float64) (raycast.Hit, bool) {
f := s.Cam.Forward()
return raycast.Cast(s.Cam.Pos, f, maxDist, func(x, y, z int32) bool {
return s.Reg.IsSolid(s.World.Block(x, y, z))
})
}
// BeginBreak 按下左键:锁定破坏目标(进度从 0 开始)。
func (s *Session) BeginBreak() {
h, ok := s.Cast(5)
if !ok {
s.EndBreak()
return
}
s.breakTarget = h
s.breaking = true
s.breakProg = 0
if s.Creative {
s.BreakNow()
}
}
// EndBreak 松开左键/换目标:进度清零。
func (s *Session) EndBreak() {
s.breaking = false
s.breakProg = 0
s.breakTarget = raycast.Hit{}
}
// BreakProgress 当前破坏进度(0–1,UI 裂纹用)。
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 // 空手
if !tool.Empty() {
if d, ok := s.Items.Get(tool.Name); ok && d.Tool != "" {
if d.Tier >= def.RequiredTier {
speed = d.Speed
} else {
speed = 0.05 // 工具门槛不足:极慢(挖矿与矿物.md §3 slow 策略)
}
}
}
if def.Hardness <= 0 {
return 1e9 // 不可破坏(如基岩 hardness=-1 → 瞬时?不:返回 0 更合理)
}
return speed / def.Hardness
}
// accumulateBreak 累积破坏进度,完成即摧毁(掉落进背包 + 耐久消耗)。
func (s *Session) accumulateBreak(dt float64) {
st := s.World.Block(s.breakTarget.X, s.breakTarget.Y, s.breakTarget.Z)
def := s.Reg.Get(st.ID())
if def.ID == 0 {
s.EndBreak() // 目标已消失
return
}
tool := s.Inv.SelectedStack()
s.breakProg += s.breakSpeed(def, tool) * dt
if s.breakProg >= 1.0 {
s.destroy(s.breakTarget)
s.EndBreak()
}
}
// destroy 摧毁方块:替换为空气 + 掉落物实体(实体系统.md §5)。
func (s *Session) destroy(h raycast.Hit) {
st := s.World.Block(h.X, h.Y, h.Z)
def := s.Reg.Get(st.ID())
s.World.SetBlock(h.X, h.Y, h.Z, block.Air)
// 方块实体随之销毁(物品与背包.md §6)
delete(s.BlockEntities, [3]int32{h.X, h.Y, h.Z})
drop := def.Drops
if drop == "" {
drop = def.Name
}
if drop != "" {
s.Entities.SpawnDrop(drop, 1, float64(h.X)+0.5, float64(h.Y), float64(h.Z)+0.5)
}
}
// BreakNow 立即破坏(创造模式)。
func (s *Session) BreakNow() {
if !s.breaking {
return
}
s.destroy(s.breakTarget)
s.EndBreak()
}
// PlaceSelected 放置选中槽物品(右键,方块交互与动画.md §6)。
func (s *Session) PlaceSelected() {
sel := s.Inv.SelectedStack()
if sel.Empty() {
return
}
d, ok := s.Items.Get(sel.Name)
if !ok || d.Place == "" {
return
}
bid, ok := s.Reg.ID(d.Place)
if !ok {
return
}
// 射线拾取放置位
h, ok := s.Cast(5)
if !ok {
return
}
px, py, pz := h.Prev[0], h.Prev[1], h.Prev[2]
// 目标必须为空且不与玩家 AABB 重叠(方块交互与动画.md §10 踩坑)
if s.World.Block(px, py, pz) != block.Air {
return
}
if s.overlapsPlayer(px, py, pz) {
return
}
s.World.SetBlock(px, py, pz, block.NewState(bid, 0))
// 方块实体创建(物品与背包.md §6:熔炉)
if d.Place == "furnace" {
s.BlockEntities[[3]int32{px, py, pz}] = blockentity.NewFurnace(px, py, pz)
}
// 消耗一个物品
s.Inv.Remove(sel.Name, 1)
}
// overlapsPlayer 放置位是否与玩家 AABB 重叠。
func (s *Session) overlapsPlayer(x, y, z int32) bool {
p := s.Player
px, py, pz := p.Pos[0], p.Pos[1], p.Pos[2]
hw := p.Width / 2
// 方块 AABB [x,x+1]×[y,y+1]×[z,z+1] 与玩家 AABB 相交判定
return float64(x)+1 > px-hw && float64(x) < px+hw &&
float64(y)+1 > py && float64(y) < py+p.Height &&
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 {
if be.Kind != blockentity.KindFurnace {
continue
}
if inputName, ok := be.Tick(); ok {
// 产出:按熔炼配方放入输出槽
if rc, found := s.Recipes.MatchSmelting(inputName); found {
be.Insert(blockentity.SlotOutput, item.Stack{Name: rc.Result.Item, Count: uint8(min(rc.Result.Count, 127))}, s.Items)
}
}
}
}
// spawnZombies 夜间刷怪(僵尸与敌对生物.md §5:方块光 ≤7、距玩家 24–48 格、每 5 秒一只)。
func (s *Session) spawnZombies(dt float64) {
s.spawnTimer -= dt
if s.spawnTimer > 0 {
return
}
s.spawnTimer = 5.0
for i := 0; i < 16; i++ {
ang := s.rng.Float64() * 2 * math.Pi
dist := 24 + s.rng.Float64()*24
x := int32(s.Player.Pos[0] + math.Cos(ang)*dist)
z := int32(s.Player.Pos[2] + math.Sin(ang)*dist)
// 找地表(从顶向下第一个非空气)
var y int32
for y = 255; y > 0; y-- {
if s.World.Block(x, y, z) != block.Air {
break
}
}
// 生成点两格空气 + 黑暗
if s.World.Block(x, y+1, z) != block.Air || s.World.Block(x, y+2, z) != block.Air {
continue
}
if s.World.BlockLight(x, y+1, z) > 7 {
continue
}
s.Entities.Spawn(entity.KindZombie, float64(x)+0.5, float64(y+1), float64(z)+0.5)
return // 每周期最多 1 只
}
}
// spawnAnimals 白天刷动物(动物体系.md §6:草面、天空光 ≥9、距玩家 16–32、每 10 秒一只)。
func (s *Session) spawnAnimals(dt float64) {
s.animalTimer -= dt
if s.animalTimer > 0 {
return
}
s.animalTimer = 10.0
for i := 0; i < 16; i++ {
ang := s.rng.Float64() * 2 * math.Pi
dist := 16 + s.rng.Float64()*16
x := int32(s.Player.Pos[0] + math.Cos(ang)*dist)
z := int32(s.Player.Pos[2] + math.Sin(ang)*dist)
var y int32
for y = 255; y > 0; y-- {
if s.World.Block(x, y, z) != block.Air {
break
}
}
if s.World.Block(x, y+1, z) != block.Air {
continue
}
// 只在草面上且白天(天空光 ≥9)
if s.World.Block(x, y, z).ID() != s.grassID() {
continue
}
if s.World.SkyLight(x, y+1, z) < 9 {
continue
}
e := s.Entities.Spawn(entity.KindAnimal, float64(x)+0.5, float64(y+1), float64(z)+0.5)
e.Data = &entity.Animal{}
return
}
}
// grassID 草方块 ID(注册表查询缓存值)。
func (s *Session) grassID() uint16 {
id, ok := s.Reg.ID("grass_block")
if !ok {
return 0
}
return id
}
// applyZombieDamage 僵尸近身伤害(僵尸与敌对生物.md §3:约 3 点/秒)。
func (s *Session) applyZombieDamage(dt float64) {
if s.Health <= 0 {
return
}
s.damageIn -= dt
if s.damageIn > 0 {
return
}
for _, e := range s.Entities.List() {
if e.Kind != entity.KindZombie || e.Dead {
continue
}
dx, dz := e.Pos[0]-s.Player.Pos[0], e.Pos[2]-s.Player.Pos[2]
if dx*dx+dz*dz < 1.6*1.6 && e.Pos[1]-s.Player.Pos[1] < 2 {
s.Health -= 3
s.damageIn = 1.0
if s.Health <= 0 {
s.Health = 0
s.respawn()
}
return
}
}
}
// respawn 死亡重生:回出生点 + 满血(实体系统.md §6)。
func (s *Session) respawn() {
s.Health = 20
s.Player.Pos = [3]float64{8.5, 120, 8.5}
s.Player.Vel = [3]float64{}
s.damageIn = 2.0
}