mirror of
https://github.com/OpenDiablo2/OpenDiablo2
synced 2024-09-06 11:34:15 -04:00
Make sure connections close properly, without weird error messages Remove player map entity when a player disconnects from a multiplayer game Close server properly when host disconnects, handle ServerClose on remote clients Don't mix JSON decoders and raw TCP writes Actually handle incoming packets from remote clients General code cleanup for simplicity and consistency
467 lines
13 KiB
Go
467 lines
13 KiB
Go
package d2client
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2hero"
|
|
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2map/d2mapgen"
|
|
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2asset"
|
|
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2math"
|
|
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2math/d2vector"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2util"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2map/d2mapengine"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2map/d2mapentity"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2records"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2networking/d2client/d2clientconnectiontype"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2networking/d2client/d2localclient"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2networking/d2client/d2remoteclient"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2networking/d2netpacket"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2networking/d2netpacket/d2netpackettype"
|
|
"github.com/OpenDiablo2/OpenDiablo2/d2script"
|
|
)
|
|
|
|
const logPrefix = "Game Client"
|
|
|
|
const (
|
|
numSubtilesPerTile = 5
|
|
)
|
|
|
|
// GameClient manages a connection to d2server.GameServer
|
|
// and keeps a synchronized copy of the map and entities.
|
|
type GameClient struct {
|
|
clientConnection ServerConnection // Abstract local/remote connection
|
|
connectionType d2clientconnectiontype.ClientConnectionType // Type of connection (local or remote)
|
|
asset *d2asset.AssetManager
|
|
scriptEngine *d2script.ScriptEngine
|
|
GameState *d2hero.HeroState // local player state
|
|
MapEngine *d2mapengine.MapEngine // Map and entities
|
|
mapGen *d2mapgen.MapGenerator // map generator
|
|
PlayerID string // ID of the local player
|
|
Players map[string]*d2mapentity.Player // IDs of the other players
|
|
Seed int64 // Map seed
|
|
RegenMap bool // Regenerate tile cache on render (map has changed)
|
|
|
|
*d2util.Logger
|
|
}
|
|
|
|
// Create constructs a new GameClient and returns a pointer to it.
|
|
func Create(connectionType d2clientconnectiontype.ClientConnectionType,
|
|
asset *d2asset.AssetManager,
|
|
l d2util.LogLevel,
|
|
scriptEngine *d2script.ScriptEngine) (*GameClient, error) {
|
|
result := &GameClient{
|
|
asset: asset,
|
|
MapEngine: d2mapengine.CreateMapEngine(l, asset),
|
|
Players: make(map[string]*d2mapentity.Player),
|
|
connectionType: connectionType,
|
|
scriptEngine: scriptEngine,
|
|
}
|
|
|
|
result.Logger = d2util.NewLogger()
|
|
result.Logger.SetPrefix(logPrefix)
|
|
result.Logger.SetLevel(l)
|
|
|
|
// for a remote client connection, set loading to true - wait until we process the GenerateMapPacket
|
|
// before we start updating map entites
|
|
result.MapEngine.IsLoading = connectionType == d2clientconnectiontype.LANClient
|
|
|
|
mapGen, err := d2mapgen.NewMapGenerator(asset, l, result.MapEngine)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result.mapGen = mapGen
|
|
|
|
switch connectionType {
|
|
case d2clientconnectiontype.LANClient:
|
|
result.clientConnection, err = d2remoteclient.Create(l, asset)
|
|
case d2clientconnectiontype.LANServer:
|
|
result.clientConnection, err = d2localclient.Create(asset, l, true)
|
|
case d2clientconnectiontype.Local:
|
|
result.clientConnection, err = d2localclient.Create(asset, l, false)
|
|
default:
|
|
err = fmt.Errorf("unknown client connection type specified: %d", connectionType)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result.clientConnection.SetClientListener(result)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Open creates the server and connects to it if the client is local.
|
|
// If the client is remote it sends a PlayerConnectionRequestPacket to the
|
|
// server (see d2netpacket).
|
|
func (g *GameClient) Open(connectionString, saveFilePath string) error {
|
|
switch g.connectionType {
|
|
case d2clientconnectiontype.LANServer, d2clientconnectiontype.Local:
|
|
g.scriptEngine.AllowEval()
|
|
}
|
|
|
|
return g.clientConnection.Open(connectionString, saveFilePath)
|
|
}
|
|
|
|
// Close destroys the server if the client is local. For remote clients
|
|
// it sends a DisconnectRequestPacket (see d2netpacket).
|
|
func (g *GameClient) Close() error {
|
|
switch g.connectionType {
|
|
case d2clientconnectiontype.LANServer, d2clientconnectiontype.Local:
|
|
g.scriptEngine.DisallowEval()
|
|
}
|
|
|
|
return g.clientConnection.Close()
|
|
}
|
|
|
|
// Destroy does the same thing as Close.
|
|
func (g *GameClient) Destroy() error {
|
|
return g.Close()
|
|
}
|
|
|
|
// OnPacketReceived is called by the ClientConection and processes incoming
|
|
// packets.
|
|
// nolint:gocyclo // switch statement on packet type makes sense, no need to change
|
|
func (g *GameClient) OnPacketReceived(packet d2netpacket.NetPacket) error {
|
|
switch packet.PacketType {
|
|
case d2netpackettype.GenerateMap:
|
|
if err := g.handleGenerateMapPacket(packet); err != nil {
|
|
return err
|
|
}
|
|
case d2netpackettype.UpdateServerInfo:
|
|
if err := g.handleUpdateServerInfoPacket(packet); err != nil {
|
|
return err
|
|
}
|
|
case d2netpackettype.AddPlayer:
|
|
if err := g.handleAddPlayerPacket(packet); err != nil {
|
|
return err
|
|
}
|
|
case d2netpackettype.MovePlayer:
|
|
if err := g.handleMovePlayerPacket(packet); err != nil {
|
|
return err
|
|
}
|
|
case d2netpackettype.CastSkill:
|
|
if err := g.handleCastSkillPacket(packet); err != nil {
|
|
return err
|
|
}
|
|
case d2netpackettype.SpawnItem:
|
|
if err := g.handleSpawnItemPacket(packet); err != nil {
|
|
return err
|
|
}
|
|
case d2netpackettype.Ping:
|
|
if err := g.handlePingPacket(); err != nil {
|
|
g.Errorf("GameClient: error responding to server ping: %s", err)
|
|
}
|
|
case d2netpackettype.PlayerDisconnectionNotification:
|
|
if err := g.handlePlayerDisconnectionPacket(packet); err != nil {
|
|
return err
|
|
}
|
|
case d2netpackettype.ServerClosed:
|
|
// https://github.com/OpenDiablo2/OpenDiablo2/issues/802
|
|
g.Infof("Server has been closed")
|
|
os.Exit(0)
|
|
case d2netpackettype.ServerFull:
|
|
g.Infof("Server is full") // need to be verified
|
|
os.Exit(0)
|
|
default:
|
|
g.Fatalf("Invalid packet type: %d", packet.PacketType)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendPacketToServer calls server.OnPacketReceived if the client is local.
|
|
// If it is remote the NetPacket sent over a UDP connection to the server.
|
|
func (g *GameClient) SendPacketToServer(packet d2netpacket.NetPacket) error {
|
|
return g.clientConnection.SendPacketToServer(packet)
|
|
}
|
|
|
|
func (g *GameClient) handleGenerateMapPacket(packet d2netpacket.NetPacket) error {
|
|
mapData, err := d2netpacket.UnmarshalGenerateMap(packet.PacketData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if mapData.RegionType == d2enum.RegionAct1Town {
|
|
g.mapGen.GenerateAct1Overworld()
|
|
}
|
|
|
|
g.RegenMap = true
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GameClient) handleUpdateServerInfoPacket(packet d2netpacket.NetPacket) error {
|
|
serverInfo, err := d2netpacket.UnmarshalUpdateServerInfo(packet.PacketData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
g.MapEngine.SetSeed(serverInfo.Seed)
|
|
g.PlayerID = serverInfo.PlayerID
|
|
g.Seed = serverInfo.Seed
|
|
g.Infof("Player id set to %s", serverInfo.PlayerID)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GameClient) handleAddPlayerPacket(packet d2netpacket.NetPacket) error {
|
|
player, err := d2netpacket.UnmarshalAddPlayer(packet.PacketData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d2hero.HydrateSkills(player.Skills, g.asset)
|
|
|
|
newPlayer := g.MapEngine.NewPlayer(player.ID, player.Name, player.X, player.Y, 0,
|
|
player.HeroType, player.Stats, player.Skills, &player.Equipment, player.LeftSkill, player.RightSkill, player.Gold)
|
|
|
|
g.Players[newPlayer.ID()] = newPlayer
|
|
g.MapEngine.AddEntity(newPlayer)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GameClient) handleSpawnItemPacket(packet d2netpacket.NetPacket) error {
|
|
item, err := d2netpacket.UnmarshalSpawnItem(packet.PacketData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
itemEntity, err := g.MapEngine.NewItem(item.X, item.Y, item.Codes...)
|
|
|
|
if err == nil {
|
|
g.MapEngine.AddEntity(itemEntity)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (g *GameClient) handleMovePlayerPacket(packet d2netpacket.NetPacket) error {
|
|
movePlayer, err := d2netpacket.UnmarshalMovePlayer(packet.PacketData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
player := g.Players[movePlayer.PlayerID]
|
|
start := d2vector.NewPositionTile(movePlayer.StartX, movePlayer.StartY)
|
|
dest := d2vector.NewPositionTile(movePlayer.DestX, movePlayer.DestY)
|
|
path := g.MapEngine.PathFind(start, dest)
|
|
|
|
if len(path) > 0 {
|
|
player.SetPath(path, func() {
|
|
tilePosition := player.Position.Tile()
|
|
tile := g.MapEngine.TileAt(int(tilePosition.X()), int(tilePosition.Y()))
|
|
|
|
if tile == nil {
|
|
return
|
|
}
|
|
|
|
player.SetIsInTown(tile.RegionType == d2enum.RegionAct1Town)
|
|
|
|
err := player.SetAnimationMode(player.GetAnimationMode())
|
|
|
|
if err != nil {
|
|
fmtStr := "GameClient: error setting animation mode for player %s: %s"
|
|
g.Errorf(fmtStr, player.ID(), err)
|
|
}
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GameClient) handleCastSkillPacket(packet d2netpacket.NetPacket) error {
|
|
playerCast, err := d2netpacket.UnmarshalCast(packet.PacketData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
player := g.Players[playerCast.SourceEntityID]
|
|
player.StopMoving()
|
|
|
|
castX := playerCast.TargetX * numSubtilesPerTile
|
|
castY := playerCast.TargetY * numSubtilesPerTile
|
|
|
|
direction := player.Position.DirectionTo(*d2vector.NewVector(castX, castY))
|
|
player.SetDirection(direction)
|
|
|
|
skillRecord := g.asset.Records.Skill.Details[playerCast.SkillID]
|
|
|
|
missileEntities, err := g.createMissileEntities(skillRecord, player, castX, castY)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var summonedNpcEntity *d2mapentity.NPC
|
|
if skillRecord.Summon != "" {
|
|
summonedNpcEntity, err = g.createSummonedNpcEntity(skillRecord, int(castX), int(castY))
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
player.StartCasting(skillRecord.Anim, func() {
|
|
if len(missileEntities) > 0 {
|
|
// shoot the missiles of the skill after the player has finished casting
|
|
for _, missileEntity := range missileEntities {
|
|
g.MapEngine.AddEntity(missileEntity)
|
|
}
|
|
}
|
|
|
|
if summonedNpcEntity != nil {
|
|
// summon the referenced NPC after the player has finished casting
|
|
g.MapEngine.AddEntity(summonedNpcEntity)
|
|
}
|
|
})
|
|
|
|
overlayRecord := g.asset.Records.Layout.Overlays[skillRecord.Castoverlay]
|
|
|
|
return g.playCastOverlay(overlayRecord, int(player.Position.X()), int(player.Position.Y()))
|
|
}
|
|
|
|
func (g *GameClient) createSummonedNpcEntity(skillRecord *d2records.SkillRecord, x, y int) (*d2mapentity.NPC, error) {
|
|
monsterStatsRecord := g.asset.Records.Monster.Stats[skillRecord.Summon]
|
|
|
|
if monsterStatsRecord == nil {
|
|
fmtErr := "cannot cast skill - No monstat entry for \"%s\""
|
|
return nil, fmt.Errorf(fmtErr, skillRecord.Summon)
|
|
}
|
|
|
|
// https://github.com/OpenDiablo2/OpenDiablo2/issues/803
|
|
summonedNpcEntity, err := g.MapEngine.NewNPC(x, y, monsterStatsRecord, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return summonedNpcEntity, nil
|
|
}
|
|
|
|
func (g *GameClient) createMissileEntities(
|
|
skillRecord *d2records.SkillRecord,
|
|
player *d2mapentity.Player,
|
|
castX, castY float64,
|
|
) ([]*d2mapentity.Missile, error) {
|
|
missileRecords := []*d2records.MissileRecord{
|
|
g.asset.Records.GetMissileByName(skillRecord.Cltmissile),
|
|
g.asset.Records.GetMissileByName(skillRecord.Cltmissilea),
|
|
g.asset.Records.GetMissileByName(skillRecord.Cltmissileb),
|
|
g.asset.Records.GetMissileByName(skillRecord.Cltmissilec),
|
|
g.asset.Records.GetMissileByName(skillRecord.Cltmissiled),
|
|
}
|
|
|
|
missileEntities := make([]*d2mapentity.Missile, 0)
|
|
|
|
for _, missileRecord := range missileRecords {
|
|
if missileRecord == nil {
|
|
continue
|
|
}
|
|
|
|
missileEntity, err := g.createMissileEntity(missileRecord, player, castX, castY)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
missileEntities = append(missileEntities, missileEntity)
|
|
}
|
|
|
|
return missileEntities, nil
|
|
}
|
|
|
|
func (g *GameClient) createMissileEntity(
|
|
missileRecord *d2records.MissileRecord,
|
|
player *d2mapentity.Player,
|
|
castX, castY float64,
|
|
) (*d2mapentity.Missile, error) {
|
|
if missileRecord == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
radians := d2math.GetRadiansBetween(
|
|
player.Position.X(),
|
|
player.Position.Y(),
|
|
castX,
|
|
castY,
|
|
)
|
|
|
|
missileEntity, err := g.MapEngine.NewMissile(
|
|
int(player.Position.X()),
|
|
int(player.Position.Y()),
|
|
g.asset.Records.Missiles[missileRecord.Id],
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
missileEntity.SetRadians(radians, func() {
|
|
g.MapEngine.RemoveEntity(missileEntity)
|
|
})
|
|
|
|
return missileEntity, nil
|
|
}
|
|
|
|
func (g *GameClient) playCastOverlay(overlayRecord *d2records.OverlayRecord, x, y int) error {
|
|
if overlayRecord == nil {
|
|
return nil
|
|
}
|
|
|
|
overlayEntity, err := g.MapEngine.NewCastOverlay(
|
|
x,
|
|
y,
|
|
overlayRecord,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
overlayEntity.SetOnDoneFunc(func() {
|
|
g.MapEngine.RemoveEntity(overlayEntity)
|
|
})
|
|
|
|
g.MapEngine.AddEntity(overlayEntity)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GameClient) handlePingPacket() error {
|
|
pongPacket, err := d2netpacket.CreatePongPacket(g.PlayerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = g.clientConnection.SendPacketToServer(pongPacket)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GameClient) handlePlayerDisconnectionPacket(packet d2netpacket.NetPacket) error {
|
|
disconnectPacket, err := d2netpacket.UnmarshalPlayerDisconnectionRequest(packet.PacketData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
player := g.Players[disconnectPacket.ID]
|
|
g.MapEngine.RemoveEntity(player)
|
|
delete(g.Players, disconnectPacket.ID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsSinglePlayer returns a bool for whether the game is a single-player game
|
|
func (g *GameClient) IsSinglePlayer() bool {
|
|
return g.connectionType == d2clientconnectiontype.Local
|
|
}
|