set_time was declared nowhere and sent never, so the client's sky was frozen wherever it started: the sun did not move, night did not fall, and nothing in the world had a time. 26.1.2 replaced the old (gameTime, dayTime, doDaylightCycle) triple with a registry of named clocks. The packet now carries a fixed-width game time plus a map from world clock to (totalTicks, partialTick, rate); the client advances each clock locally at its rate and drives the minecraft:day timeline -- a keyframe track over a 24000-tick period -- from the overworld clock. The clock registry is already among the 28 we sync, so the id is looked up rather than hardcoded and the server refuses to start if it is missing. The counter rides the entity tick loop because that is the only loop already running at 20 TPS. It belongs on the single authoritative tick the engine still needs; putting a sixth ticker beside the five that exist would make that worse. Broadcast every second, which is what vanilla does -- the client interpolates in between, so the resend only corrects drift. The clock also persists now. The world metadata file was written once and never touched again; it is atomically rewritable, carries gameTime and dayTime, and a file written before those fields existed still opens and resumes at dawn as it did. Saved every 30 seconds alongside the chunk autosave, and once more on shutdown after the final flush.
698 lines
20 KiB
Go
698 lines
20 KiB
Go
package network
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"time"
|
|
|
|
"regionio/internal/nbt"
|
|
"regionio/internal/protocol"
|
|
"regionio/internal/registry"
|
|
"regionio/internal/server"
|
|
"regionio/internal/world"
|
|
)
|
|
|
|
// Spawn column. The feet-level Y is resolved from the generated surface when
|
|
// the player enters the play phase.
|
|
const (
|
|
spawnX = 8.5
|
|
spawnZ = 8.5
|
|
)
|
|
|
|
// beginPlay sends the join sequence once the client enters the Play phase and
|
|
// hands chunk streaming off to the background streamer. The streamer stops when
|
|
// h.ctx (the connection lifetime context) is cancelled.
|
|
func (h *handler) beginPlay() error {
|
|
session, err := h.srv.RegisterPlayer(h.conn.Profile, h.conn.Send)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.session = session
|
|
spawnY, ok := h.srv.Chunks().SafeSpawnY(int(math.Floor(spawnX)), int(math.Floor(spawnZ)))
|
|
if !ok {
|
|
spawnY = world.SeaLevel + 1
|
|
}
|
|
h.spawnY = float64(spawnY)
|
|
h.srv.SetPlayerTransform(session, spawnX, h.spawnY, spawnZ, 0, 0, true)
|
|
h.srv.SetPlayerViewDistance(session, h.visibilityRadius())
|
|
for i := range h.hotbar {
|
|
h.hotbar[i] = -1 // empty
|
|
}
|
|
if err := h.sendPlayLogin(); err != nil {
|
|
return err
|
|
}
|
|
// "Start waiting for level chunks": tells the client to show the loading
|
|
// screen until chunks arrive.
|
|
if err := h.sendGameEvent(protocol.GameEventStartWaitingChunks, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := h.sendPlayerPosition(1); err != nil {
|
|
return err
|
|
}
|
|
if err := h.sendChunkCacheCenter(0, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := h.sendDefaultSpawnPosition(); err != nil {
|
|
return err
|
|
}
|
|
if err := h.sendPlayerAbilities(); err != nil {
|
|
return err
|
|
}
|
|
// The client's sky stays where it started until it is told the time.
|
|
if err := h.conn.Send(protocol.PlaySetTime, h.srv.SetTimePacket()); err != nil {
|
|
return err
|
|
}
|
|
// Launch the background chunk streamer. It owns generation + sending so the
|
|
// read loop stays free; requestRecenter is a non-blocking push.
|
|
h.streamer = newStreamer(h.srv.Chunks(), h.conn, h.log, h.visibilityRadius())
|
|
go h.streamer.run(h.ctx)
|
|
h.streamer.requestRecenter(0, 0)
|
|
go h.keepAliveLoop()
|
|
go h.entitySyncLoop()
|
|
return nil
|
|
}
|
|
|
|
func (h *handler) sendChunkCacheCenter(cx, cz int32) error {
|
|
w := protocol.NewWriter(8)
|
|
w.VarInt(cx)
|
|
w.VarInt(cz)
|
|
return h.conn.SendWriter(protocol.PlayChunkCacheCenter, w)
|
|
}
|
|
|
|
func (h *handler) sendDefaultSpawnPosition() error {
|
|
// 26.1.2 wraps the spawn in LevelData.RespawnData: GlobalPos followed by
|
|
// yaw and pitch. GlobalPos starts with the dimension resource key.
|
|
w := protocol.NewWriter(40)
|
|
w.String("minecraft:overworld")
|
|
w.Position(8, int(math.Floor(h.spawnY)), 8)
|
|
w.Float32(0.0)
|
|
w.Float32(0.0)
|
|
return h.conn.SendWriter(protocol.PlayDefaultSpawnPos, w)
|
|
}
|
|
|
|
func (h *handler) sendPlayerAbilities() error {
|
|
w := protocol.NewWriter(9)
|
|
w.Byte(0x0F)
|
|
w.Float32(0.05)
|
|
w.Float32(0.1)
|
|
return h.conn.SendWriter(protocol.PlayAbilities, w)
|
|
}
|
|
|
|
// onPlayerMove recenters the streamer when the player crosses into a new chunk.
|
|
// It is a non-blocking push; the read loop never waits on generation.
|
|
func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround bool) error {
|
|
if math.IsNaN(x) || math.IsNaN(y) || math.IsNaN(z) ||
|
|
math.IsInf(x, 0) || math.IsInf(y, 0) || math.IsInf(z, 0) ||
|
|
math.IsNaN(float64(yaw)) || math.IsNaN(float64(pitch)) ||
|
|
math.IsInf(float64(yaw), 0) || math.IsInf(float64(pitch), 0) {
|
|
return errors.New("invalid player position")
|
|
}
|
|
h.srv.SetPlayerTransform(h.session, x, y, z, yaw, pitch, onGround)
|
|
cx := int32(int64(math.Floor(x)) >> 4)
|
|
cz := int32(int64(math.Floor(z)) >> 4)
|
|
if h.streamer != nil {
|
|
h.streamer.requestRecenter(cx, cz)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *handler) visibilityRadius() int {
|
|
distance := h.viewDistance
|
|
if distance < 2 {
|
|
distance = defaultViewRadius
|
|
}
|
|
if max := h.srv.Config().MaxViewDistance; distance > max {
|
|
distance = max
|
|
}
|
|
return distance
|
|
}
|
|
|
|
// sendPlayLogin writes the clientbound play "login" packet. Field layout was
|
|
// confirmed against the 26.1.2 vanilla server capture.
|
|
func (h *handler) sendPlayLogin() error {
|
|
dimTypeIdx := registry.Index("minecraft:dimension_type", "minecraft:overworld")
|
|
if dimTypeIdx < 0 {
|
|
dimTypeIdx = 0
|
|
}
|
|
|
|
w := protocol.NewWriter(128)
|
|
entityID := int32(1)
|
|
if h.session != nil {
|
|
entityID = h.session.EntityID
|
|
}
|
|
w.Int32(entityID) // entity ID
|
|
w.Bool(false) // is hardcore
|
|
|
|
// Dimension names: the worlds available on this server.
|
|
dims := []string{"minecraft:overworld", "minecraft:the_end", "minecraft:the_nether"}
|
|
w.VarInt(int32(len(dims)))
|
|
for _, d := range dims {
|
|
w.String(d)
|
|
}
|
|
|
|
w.VarInt(int32(h.srv.Config().MaxPlayers)) // max players (legacy)
|
|
w.VarInt(int32(h.visibilityRadius())) // view distance
|
|
w.VarInt(int32(h.visibilityRadius())) // simulation distance
|
|
w.Bool(false) // reduced debug info
|
|
w.Bool(true) // enable respawn screen
|
|
w.Bool(false) // do limited crafting
|
|
|
|
w.VarInt(int32(dimTypeIdx)) // dimension type (registry index)
|
|
w.String("minecraft:overworld") // dimension name (this world)
|
|
w.Int64(0) // hashed seed
|
|
w.Byte(1) // game mode: creative (instant break, creative inventory)
|
|
w.Byte(0xFF) // previous game mode: -1 (none)
|
|
w.Bool(false) // is debug
|
|
w.Bool(false) // is flat
|
|
w.Bool(false) // has death location
|
|
w.VarInt(0) // portal cooldown
|
|
w.VarInt(63) // sea level (overworld)
|
|
w.Bool(false) // enforces secure chat
|
|
|
|
return h.conn.SendWriter(protocol.PlayLogin, w)
|
|
}
|
|
|
|
// sendGameEvent writes a game_event packet (event id + float value).
|
|
func (h *handler) sendGameEvent(event byte, value float32) error {
|
|
w := protocol.NewWriter(5)
|
|
w.Byte(event)
|
|
w.Float32(value)
|
|
return h.conn.SendWriter(protocol.PlayGameEvent, w)
|
|
}
|
|
|
|
// sendPlayerPosition teleports the player to spawn. The client must echo the
|
|
// teleport ID back via accept_teleportation.
|
|
func (h *handler) sendPlayerPosition(teleportID int32) error {
|
|
w := protocol.NewWriter(64)
|
|
w.VarInt(teleportID)
|
|
w.Float64(spawnX).Float64(h.spawnY).Float64(spawnZ) // position
|
|
w.Float64(0).Float64(0).Float64(0) // velocity
|
|
w.Float32(0) // yaw
|
|
w.Float32(0) // pitch
|
|
w.Int32(0) // relative flags
|
|
return h.conn.SendWriter(protocol.PlayPlayerPosition, w)
|
|
}
|
|
|
|
// keepAliveLoop sends a keep-alive every 15 seconds. It exits as soon as a send
|
|
// fails, which happens when the connection closes.
|
|
func (h *handler) keepAliveLoop() {
|
|
ticker := time.NewTicker(15 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-h.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
id := time.Now().UnixMilli()
|
|
w := protocol.NewWriter(8)
|
|
w.Int64(id)
|
|
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// visibleEntity is the comparable state retained by one client's visibility
|
|
// tracker. OnGround is separate because mobs currently always use true.
|
|
type visibleEntity struct {
|
|
entity world.Entity
|
|
onGround bool
|
|
}
|
|
|
|
// entitySyncLoop maintains tab-list membership and chunk-scoped entity state.
|
|
func (h *handler) entitySyncLoop() {
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-h.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := h.syncVisibleEntities(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// syncVisibleEntities performs one deterministic visibility pass. Keeping it
|
|
// separate from the ticker makes the four-client workflow integration-testable.
|
|
func (h *handler) syncVisibleEntities() error {
|
|
if h.session == nil {
|
|
return nil
|
|
}
|
|
if h.knownPlayers == nil {
|
|
h.knownPlayers = make(map[[16]byte]bool)
|
|
}
|
|
if h.knownEntities == nil {
|
|
h.knownEntities = make(map[int32]visibleEntity)
|
|
}
|
|
|
|
viewer := h.session.Snapshot()
|
|
players := h.srv.PlayerSnapshots()
|
|
currentPlayers := make(map[[16]byte]bool, len(players))
|
|
for _, player := range players {
|
|
currentPlayers[player.Profile.UUID] = true
|
|
if !h.knownPlayers[player.Profile.UUID] {
|
|
if err := h.sendPlayerInfoAdd(player.Profile); err != nil {
|
|
return err
|
|
}
|
|
h.knownPlayers[player.Profile.UUID] = true
|
|
}
|
|
}
|
|
|
|
currentEntities := make(map[int32]visibleEntity)
|
|
for _, player := range players {
|
|
if player.EntityID == viewer.EntityID || !playerVisible(viewer, player, h.visibilityRadius()) ||
|
|
!h.entityChunkResident(player.X, player.Z) {
|
|
continue
|
|
}
|
|
currentEntities[player.EntityID] = visibleEntity{
|
|
entity: world.Entity{
|
|
ID: player.EntityID, UUID: player.Profile.UUID,
|
|
TypeID: registry.EntityTypeIndex("minecraft:player"), TypeName: "minecraft:player",
|
|
X: player.X, Y: player.Y, Z: player.Z,
|
|
Yaw: player.Yaw, Pitch: player.Pitch, HeadYaw: player.Yaw,
|
|
},
|
|
onGround: player.OnGround,
|
|
}
|
|
}
|
|
for _, entity := range h.srv.Entities().All() {
|
|
if entityVisible(viewer, entity, h.visibilityRadius()) && h.entityChunkResident(entity.X, entity.Z) {
|
|
currentEntities[entity.ID] = visibleEntity{entity: entity, onGround: true}
|
|
}
|
|
}
|
|
|
|
for id, current := range currentEntities {
|
|
known, exists := h.knownEntities[id]
|
|
if !exists {
|
|
entity := current.entity
|
|
if err := h.sendAddEntity(&entity); err != nil {
|
|
return err
|
|
}
|
|
} else if known != current {
|
|
entity := current.entity
|
|
if err := h.sendEntityTeleportState(&entity, current.onGround); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for id := range h.knownEntities {
|
|
if _, visible := currentEntities[id]; !visible {
|
|
if err := h.sendRemoveEntity(id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
h.knownEntities = currentEntities
|
|
|
|
for uuid := range h.knownPlayers {
|
|
if !currentPlayers[uuid] {
|
|
if err := h.sendPlayerInfoRemove(uuid); err != nil {
|
|
return err
|
|
}
|
|
delete(h.knownPlayers, uuid)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *handler) entityChunkResident(x, z float64) bool {
|
|
if h.streamer == nil {
|
|
return true
|
|
}
|
|
cx := int32(int64(math.Floor(x)) >> 4)
|
|
cz := int32(int64(math.Floor(z)) >> 4)
|
|
return h.streamer.isResident(cx, cz)
|
|
}
|
|
|
|
func playerVisible(viewer, target server.PlayerSnapshot, radius int) bool {
|
|
return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius)
|
|
}
|
|
|
|
func entityVisible(viewer server.PlayerSnapshot, target world.Entity, radius int) bool {
|
|
return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius)
|
|
}
|
|
|
|
func chunksWithin(ax, az, bx, bz float64, radius int) bool {
|
|
acx := int32(int64(math.Floor(ax)) >> 4)
|
|
acz := int32(int64(math.Floor(az)) >> 4)
|
|
bcx := int32(int64(math.Floor(bx)) >> 4)
|
|
bcz := int32(int64(math.Floor(bz)) >> 4)
|
|
dx := acx - bcx
|
|
if dx < 0 {
|
|
dx = -dx
|
|
}
|
|
dz := acz - bcz
|
|
if dz < 0 {
|
|
dz = -dz
|
|
}
|
|
return dx <= int32(radius) && dz <= int32(radius)
|
|
}
|
|
|
|
func (h *handler) sendPlayerInfoAdd(profile server.Profile) error {
|
|
w := protocol.NewWriter(64)
|
|
w.Byte(0xff) // All eight initialization actions, fixed 8-bit EnumSet.
|
|
w.VarInt(1)
|
|
w.UUID(profile.UUID)
|
|
w.String(profile.Name)
|
|
w.VarInt(0) // profile properties
|
|
w.Bool(false) // no signed chat session
|
|
w.VarInt(1) // creative game mode
|
|
w.Bool(true) // listed
|
|
w.VarInt(0) // latency
|
|
w.Bool(false) // no custom display name
|
|
w.VarInt(0) // list order
|
|
w.Bool(true) // show hat
|
|
return h.conn.SendWriter(protocol.PlayPlayerInfoUpdate, w)
|
|
}
|
|
|
|
func (h *handler) sendPlayerInfoRemove(uuid [16]byte) error {
|
|
w := protocol.NewWriter(20)
|
|
w.VarInt(1)
|
|
w.UUID(uuid)
|
|
return h.conn.SendWriter(protocol.PlayPlayerInfoRemove, w)
|
|
}
|
|
|
|
// sendAddEntity sends the minecraft:add_entity packet.
|
|
func (h *handler) sendAddEntity(e *world.Entity) error {
|
|
w := protocol.NewWriter(64)
|
|
w.VarInt(e.ID)
|
|
w.UUID(e.UUID)
|
|
w.VarInt(int32(e.TypeID))
|
|
w.Float64(e.X).Float64(e.Y).Float64(e.Z)
|
|
w.LPVec3(
|
|
float64(e.VelocityX)/8000.0,
|
|
float64(e.VelocityY)/8000.0,
|
|
float64(e.VelocityZ)/8000.0,
|
|
)
|
|
w.Byte(byte(e.Pitch * 256.0 / 360.0))
|
|
w.Byte(byte(e.Yaw * 256.0 / 360.0))
|
|
w.Byte(byte(e.HeadYaw * 256.0 / 360.0))
|
|
w.VarInt(0) // Data
|
|
return h.conn.SendWriter(protocol.PlayAddEntity, w)
|
|
}
|
|
|
|
func (h *handler) sendEntityTeleport(e *world.Entity) error {
|
|
return h.sendEntityTeleportState(e, true)
|
|
}
|
|
|
|
func (h *handler) sendEntityTeleportState(e *world.Entity, onGround bool) error {
|
|
w := protocol.NewWriter(64)
|
|
w.VarInt(e.ID)
|
|
// PositionMoveRotation: position, deltaMovement, yRot, xRot.
|
|
w.Float64(e.X).Float64(e.Y).Float64(e.Z)
|
|
w.Float64(float64(e.VelocityX) / 8000.0)
|
|
w.Float64(float64(e.VelocityY) / 8000.0)
|
|
w.Float64(float64(e.VelocityZ) / 8000.0)
|
|
w.Float32(e.Yaw)
|
|
w.Float32(e.Pitch)
|
|
w.Int32(0) // Relative.SET_STREAM_CODEC uses ByteBufCodecs.INT; no relative flags.
|
|
w.Bool(onGround)
|
|
return h.conn.SendWriter(protocol.PlayTeleportEntity, w)
|
|
}
|
|
|
|
func (h *handler) sendRemoveEntity(id int32) error {
|
|
w := protocol.NewWriter(16)
|
|
w.VarInt(1) // count
|
|
w.VarInt(id)
|
|
return h.conn.SendWriter(protocol.PlayRemoveEntities, w)
|
|
}
|
|
|
|
// handlePlay dispatches serverbound play packets. Most are tolerated for now;
|
|
// teleport and keep-alive are acknowledged/logged.
|
|
func (h *handler) handlePlay(pkt protocol.Packet) error {
|
|
switch pkt.ID {
|
|
case protocol.PlayAcceptTeleport:
|
|
id, err := pkt.Body().VarInt()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.log.Debug("teleport confirmed", "id", id)
|
|
return nil
|
|
|
|
case protocol.PlayKeepAliveServer:
|
|
// A response to our keep-alive; presence is enough for liveness.
|
|
h.log.Debug("keep-alive ack")
|
|
return nil
|
|
|
|
case protocol.PlayPlayerLoaded:
|
|
h.log.Info("player loaded into world", "name", h.conn.Profile.Name)
|
|
return nil
|
|
|
|
case protocol.PlayMovePos, protocol.PlayMovePosRot:
|
|
r := pkt.Body()
|
|
x, err := r.Float64()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
y, err := r.Float64() // feet Y
|
|
if err != nil {
|
|
return err
|
|
}
|
|
z, err := r.Float64()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snapshot := h.session.Snapshot()
|
|
yaw, pitch := snapshot.Yaw, snapshot.Pitch
|
|
if pkt.ID == protocol.PlayMovePosRot {
|
|
yaw, err = r.Float32()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pitch, err = r.Float32()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
flags, err := r.ReadByte()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return h.onPlayerMove(x, y, z, yaw, pitch, flags&1 != 0)
|
|
|
|
case protocol.PlayMoveRot:
|
|
r := pkt.Body()
|
|
yaw, err := r.Float32()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pitch, err := r.Float32()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
flags, err := r.ReadByte()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snapshot := h.session.Snapshot()
|
|
return h.onPlayerMove(snapshot.X, snapshot.Y, snapshot.Z, yaw, pitch, flags&1 != 0)
|
|
|
|
case protocol.PlayMoveStatusOnly:
|
|
flags, err := pkt.Body().ReadByte()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snapshot := h.session.Snapshot()
|
|
return h.onPlayerMove(snapshot.X, snapshot.Y, snapshot.Z, snapshot.Yaw, snapshot.Pitch, flags&1 != 0)
|
|
|
|
case protocol.PlayPlayerAction:
|
|
return h.handlePlayerAction(pkt)
|
|
|
|
case protocol.PlayChatMessage:
|
|
return h.handleChat(pkt)
|
|
|
|
case protocol.PlayUseItemOn:
|
|
return h.handleUseItemOn(pkt)
|
|
|
|
case protocol.PlaySetCarriedItem:
|
|
slot, err := pkt.Body().Uint16()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if slot < 9 {
|
|
h.heldSlot = int32(slot)
|
|
}
|
|
return nil
|
|
|
|
case protocol.PlaySetCreativeSlot:
|
|
return h.handleCreativeSlot(pkt)
|
|
|
|
default:
|
|
h.log.Debug("play packet ignored", "id", pkt.ID)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// handleChat reads a chat message (only the leading text field is needed) and
|
|
// echoes it to the player as a system message prefixed with their name. Once a
|
|
// player registry exists this will broadcast to everyone.
|
|
func (h *handler) handleChat(pkt protocol.Packet) error {
|
|
msg, err := pkt.Body().String()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
line := "<" + h.conn.Profile.Name + "> " + msg
|
|
h.log.Info("chat", "msg", line)
|
|
return h.broadcastSystemChat(line)
|
|
}
|
|
|
|
func (h *handler) broadcastSystemChat(text string) error {
|
|
w := protocol.NewWriter(len(text) + 8)
|
|
w.Raw(nbt.Marshal(nbt.String(text)))
|
|
w.Bool(false)
|
|
h.srv.Broadcast(protocol.PlaySystemChat, w.Bytes())
|
|
return nil
|
|
}
|
|
|
|
// handlePlayerAction processes digging. In creative the client sends
|
|
// START_DESTROY_BLOCK (status 0) for an instant break; survival also sends
|
|
// STOP/FINISH (status 2). Either way we clear the block, push a block_update,
|
|
// and acknowledge the sequence so the client keeps its predicted change.
|
|
func (h *handler) handlePlayerAction(pkt protocol.Packet) error {
|
|
r := pkt.Body()
|
|
status, err := r.VarInt()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
x, y, z, err := r.Position()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.ReadByte(); err != nil { // face
|
|
return err
|
|
}
|
|
seq, err := r.VarInt()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
const startDig, finishDig = 0, 2
|
|
if status == startDig || status == finishDig {
|
|
if valid, lightChunks := h.srv.Chunks().SetBlockWithLight(x, y, z, world.StateAir); valid {
|
|
h.broadcastBlockUpdate(x, y, z, world.StateAir, lightChunks)
|
|
h.log.Debug("block broken", "x", x, "y", y, "z", z)
|
|
}
|
|
}
|
|
return h.sendBlockChangedAck(seq)
|
|
}
|
|
|
|
// hotbarInvStart is the inventory slot index of hotbar slot 0.
|
|
const hotbarInvStart = 36
|
|
|
|
// handleCreativeSlot records the item a creative player placed into a slot so we
|
|
// know what block to place. The packet is: Short slot, then an item stack
|
|
// (VarInt count; if non-empty, VarInt item id followed by components we ignore).
|
|
func (h *handler) handleCreativeSlot(pkt protocol.Packet) error {
|
|
r := pkt.Body()
|
|
slot, err := r.Uint16()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hotbarIdx := int(slot) - hotbarInvStart
|
|
if hotbarIdx < 0 || hotbarIdx >= len(h.hotbar) {
|
|
return nil // not a hotbar slot; ignored
|
|
}
|
|
|
|
count, err := r.VarInt()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count <= 0 {
|
|
h.hotbar[hotbarIdx] = -1 // emptied
|
|
return nil
|
|
}
|
|
itemID, err := r.VarInt()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.hotbar[hotbarIdx] = itemID // remaining component data is not needed
|
|
return nil
|
|
}
|
|
|
|
// faceOffsets maps a Direction (block face) to the unit offset of the block
|
|
// placed against it: DOWN, UP, NORTH, SOUTH, WEST, EAST.
|
|
var faceOffsets = [6][3]int{
|
|
{0, -1, 0}, {0, 1, 0}, {0, 0, -1}, {0, 0, 1}, {-1, 0, 0}, {1, 0, 0},
|
|
}
|
|
|
|
// handleUseItemOn places the held block against the clicked face. Layout
|
|
// (captured from the client): Hand, Position, Face, cursor XYZ floats,
|
|
// insideBlock bool, worldBorderHit bool, sequence.
|
|
func (h *handler) handleUseItemOn(pkt protocol.Packet) error {
|
|
r := pkt.Body()
|
|
if _, err := r.VarInt(); err != nil { // hand
|
|
return err
|
|
}
|
|
x, y, z, err := r.Position()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
face, err := r.VarInt()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Skip cursor (3 floats) + insideBlock + worldBorderHit, then read sequence.
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := r.Float32(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := r.Bool(); err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.Bool(); err != nil {
|
|
return err
|
|
}
|
|
seq, err := r.VarInt()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if face >= 0 && int(face) < len(faceOffsets) {
|
|
if state, ok := h.heldBlock(); ok {
|
|
off := faceOffsets[face]
|
|
px, py, pz := x+off[0], y+off[1], z+off[2]
|
|
if valid, lightChunks := h.srv.Chunks().SetBlockWithLight(px, py, pz, state); valid {
|
|
h.broadcastBlockUpdate(px, py, pz, state, lightChunks)
|
|
h.log.Debug("block placed", "x", px, "y", py, "z", pz, "state", state)
|
|
}
|
|
}
|
|
}
|
|
return h.sendBlockChangedAck(seq)
|
|
}
|
|
|
|
// heldBlock returns the block state for the currently held item, if it is a
|
|
// placeable block.
|
|
func (h *handler) heldBlock() (uint16, bool) {
|
|
itemID := h.hotbar[h.heldSlot]
|
|
if itemID < 0 {
|
|
return 0, false
|
|
}
|
|
return world.ItemToBlock(itemID)
|
|
}
|
|
|
|
func (h *handler) broadcastBlockUpdate(x, y, z int, state uint16, lightChunks []world.ChunkPos) {
|
|
w := protocol.NewWriter(12)
|
|
w.Position(x, y, z)
|
|
w.VarInt(int32(state))
|
|
cx := int32(x >> 4)
|
|
cz := int32(z >> 4)
|
|
h.srv.BroadcastChunk(cx, cz, protocol.PlayBlockUpdate, w.Bytes())
|
|
for _, chunk := range lightChunks {
|
|
if light, err := h.srv.Chunks().LightUpdate(chunk.X, chunk.Z); err == nil {
|
|
h.srv.BroadcastChunk(chunk.X, chunk.Z, protocol.PlayLightUpdate, light)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendBlockChangedAck confirms a block-action sequence so the client does not
|
|
// roll back its predicted change.
|
|
func (h *handler) sendBlockChangedAck(sequence int32) error {
|
|
w := protocol.NewWriter(4)
|
|
w.VarInt(sequence)
|
|
return h.conn.SendWriter(protocol.PlayBlockChangedAck, w)
|
|
}
|