Initial cast overlay implementation. Fix HeroSkill deserialization & map entities processing crashing for remote client. (#766)

* Casting a skill now plays the corresponding overlay(if any).

* Prevent a crash caused by nil pointer in HeroSkill deserialization, happening when
unmarshalling HeroSkill from packets as a remote client.

* Add PlayerAnimationModeNone to handle some of the Skills(e.g.
Paladin auras) having "" as animation mode.

* Joining a game as remote client now waits for map generation to finish
before rendering map or processing map entities. This is temporary hack to prevent the game from
crashing due to concurrent map read & write exception.

* Send CastSkill packet to other clients.

Co-authored-by: Presiyan Ivanov <presiyan-ivanov@users.noreply.github.com>
This commit is contained in:
presiyan-ivanov 2020-10-11 01:47:51 +03:00 committed by GitHub
parent 7be3b7b98d
commit 88326b5278
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 298 additions and 52 deletions

View File

@ -27,4 +27,5 @@ const (
PlayerAnimationModeDead // DD
PlayerAnimationModeSequence // GH
PlayerAnimationModeKnockBack // GH
PlayerAnimationModeNone // "" - aura skills, e.g. Paladin's Concentration Aura
)

View File

@ -23,13 +23,8 @@ type shallowHeroSkill struct {
// MarshalJSON overrides the default logic used when the HeroSkill is serialized to a byte array.
func (hs *HeroSkill) MarshalJSON() ([]byte, error) {
// only serialize the ID instead of the whole SkillRecord object.
shallow := shallowHeroSkill{
SkillID: hs.SkillRecord.ID,
SkillPoints: hs.SkillPoints,
}
bytes, err := json.Marshal(shallow)
// only serialize the shallow object instead of the SkillRecord & SkillDescriptionRecord
bytes, err := json.Marshal(hs.shallow)
if err != nil {
log.Fatalln(err)
}

View File

@ -0,0 +1,15 @@
package d2hero
import "github.com/OpenDiablo2/OpenDiablo2/d2core/d2asset"
// HydrateSkills will load the SkillRecord & SkillDescriptionRecord from the asset manager, using the skill ID.
// This is done to avoid serializing the whole record data of HeroSkill to a game save or network packets.
// We cant do this while unmarshalling because there is no reference to the asset manager.
func HydrateSkills(skills map[int]*HeroSkill, asset *d2asset.AssetManager) {
for skillID := range skills {
heroSkill := skills[skillID]
heroSkill.SkillRecord = asset.Records.Skill.Details[skillID]
heroSkill.SkillDescriptionRecord = asset.Records.Skill.Descriptions[heroSkill.SkillRecord.Skilldesc]
heroSkill.SkillPoints = skills[skillID].SkillPoints
}
}

View File

@ -31,6 +31,8 @@ type MapEngine struct {
startSubTileX int // Starting X position
startSubTileY int // Starting Y position
dt1Files []string // List of DS1 strings
// TODO: remove this flag and show loading screen until the initial server packets are handled and the map is generated (only for remote client)
IsLoading bool // (temp) Whether we have processed the GenerateMapPacket(only for remote client)
}
// CreateMapEngine creates a new instance of the map engine and returns a pointer to it.
@ -42,6 +44,8 @@ func CreateMapEngine(asset *d2asset.AssetManager) *MapEngine {
asset: asset,
MapEntityFactory: entity,
StampFactory: stamp,
// This will be set to true when we are using a remote client connection, and then set to false after we process the GenerateMapPacket
IsLoading: false,
}
return engine
@ -285,6 +289,12 @@ func (m *MapEngine) GetCenterPosition() (x, y float64) {
// Advance calls the Advance() method for all entities,
// processing a single tick.
func (m *MapEngine) Advance(tickTime float64) {
// TODO:(temp hack) prevents concurrent map read & write exceptions that occur when we join a TCP game as a remote client
// due to the engine updating entities before handling the GenerateMapPacket
if m.IsLoading {
return
}
for ID := range m.entities {
m.entities[ID].Advance(tickTime)
}

View File

@ -0,0 +1,57 @@
package d2mapentity
import (
"math"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2math/d2vector"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2records"
)
// CastOverlay is an animated entity representing a projectile that is a result of a skill cast.
type CastOverlay struct {
*AnimatedEntity
record *d2records.OverlayRecord
playLoop bool
onDoneFunc func()
}
// ID returns the overlay uuid
func (co *CastOverlay) ID() string {
return co.AnimatedEntity.uuid
}
// GetPosition returns the position of the overlay
func (co *CastOverlay) GetPosition() d2vector.Position {
return co.AnimatedEntity.Position
}
// GetVelocity returns the velocity vector of the overlay
func (co *CastOverlay) GetVelocity() d2vector.Vector {
return co.AnimatedEntity.velocity
}
// SetRadians adjusts the entity target based on it's range, rotating it's
// current destination by the value of angle in radians.
func (co *CastOverlay) SetRadians(angle float64, done func()) {
rads := float64(co.record.Height2) // TODO:
x := co.Position.X() + (rads * math.Cos(angle))
y := co.Position.Y() + (rads * math.Sin(angle))
co.setTarget(d2vector.NewPosition(x, y), done)
}
// SetOnDoneFunc changes the handler func that gets called when the overlay finishes playing.
func (co *CastOverlay) SetOnDoneFunc(onDoneFunc func()) {
co.onDoneFunc = onDoneFunc
}
// Advance is called once per frame and processes a single game tick.
func (co *CastOverlay) Advance(tickTime float64) {
co.Step(tickTime)
co.AnimatedEntity.Advance(tickTime)
if !co.playLoop && co.AnimatedEntity.animation.GetPlayedCount() >= 1 {
co.onDoneFunc()
}
}

View File

@ -218,6 +218,43 @@ func (f *MapEntityFactory) NewNPC(x, y int, monstat *d2records.MonStatsRecord, d
return result, nil
}
// NewCastOverlay creates a cast overlay map entity
func (f *MapEntityFactory) NewCastOverlay(x, y int, overlayRecord *d2records.OverlayRecord) (*CastOverlay, error) {
animation, err := f.asset.LoadAnimationWithEffect(
fmt.Sprintf("/data/Global/Overlays/%s.dcc", overlayRecord.Filename),
d2resource.PaletteUnits,
d2enum.DrawEffectModulate,
)
// TODO: Frame index and played count seem to be shared across the cloned animation objects when we retrieve the animation from the asset manager cache.
animation.Rewind()
animation.ResetPlayedCount()
if err != nil {
return nil, err
}
animationSpeed := float64(overlayRecord.AnimRate*25.0) / 1000.0
playLoop := false // TODO: should be based on the overlay record, some overlays can repeat(e.g. Bone Shield, Frozen Armor)
animation.SetPlayLength(animationSpeed)
animation.SetPlayLoop(playLoop)
animation.PlayForward()
targetX := x + overlayRecord.XOffset
targetY := y + overlayRecord.YOffset
entity := NewAnimatedEntity(targetX, targetY, animation)
result := &CastOverlay{
AnimatedEntity: entity,
record: overlayRecord,
playLoop: playLoop,
}
return result, nil
}
// NewObject creates an instance of AnimatedComposite
func (f *MapEntityFactory) NewObject(x, y int, objectRec *d2records.ObjectDetailsRecord,
palettePath string) (*Object, error) {

View File

@ -185,12 +185,19 @@ func (p *Player) IsCasting() bool {
// StartCasting sets a flag indicating the player is casting a skill and
// sets the animation mode to the casting animation.
func (p *Player) StartCasting(onFinishedCasting func()) {
// This handles all types of skills - melee, ranged, kick, summon, etc.
func (p *Player) StartCasting(animMode d2enum.PlayerAnimationMode, onFinishedCasting func()) {
// passive skills, auras, etc.
if animMode == d2enum.PlayerAnimationModeNone {
return
}
p.isCasting = true
p.onFinishedCasting = onFinishedCasting
if err := p.SetAnimationMode(d2enum.PlayerAnimationModeCast); err != nil {
if err := p.SetAnimationMode(animMode); err != nil {
fmtStr := "failed to set animationMode of player: %s to: %d, err: %v\n"
fmt.Printf(fmtStr, p.ID(), d2enum.PlayerAnimationModeCast, err)
fmt.Printf(fmtStr, p.ID(), animMode, err)
}
}

View File

@ -124,6 +124,12 @@ func (mr *MapRenderer) SetMapEngine(mapEngine *d2mapengine.MapEngine) {
//
// Pass 4: Roof tiles.
func (mr *MapRenderer) Render(target d2interface.Surface) {
// TODO:(temp hack) should not render before the map has been fully generated -
// Prevents concurrent map read & write exceptions that otherwise occur when we join a TCP game
// as a remote client, due to rendering before we have handled the GenerateMapPacket.
if mr.mapEngine.IsLoading {
return
}
mapSize := mr.mapEngine.Size()
stxf, styf := mr.viewport.ScreenToWorld(screenMiddleX, -200)

View File

@ -4,6 +4,7 @@ import (
"log"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2calculation/d2parser"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2fileformats/d2txt"
)
@ -140,7 +141,7 @@ func skillDetailsLoader(r *RecordManager, d *d2txt.DataDictionary) error {
Itypeb3: d.String("itypeb3"),
Etypeb1: d.String("etypeb1"),
Etypeb2: d.String("etypeb2"),
Anim: d.String("anim"),
Anim: animToEnum(d.String("anim")),
Seqtrans: d.String("seqtrans"),
Monanim: d.String("monanim"),
Seqnum: d.Number("seqnum"),
@ -275,3 +276,47 @@ func skillDetailsLoader(r *RecordManager, d *d2txt.DataDictionary) error {
return nil
}
func animToEnum(anim string) d2enum.PlayerAnimationMode {
switch anim {
case "SC":
return d2enum.PlayerAnimationModeCast
case "TH":
return d2enum.PlayerAnimationModeThrow
case "KK":
return d2enum.PlayerAnimationModeKick
case "SQ":
return d2enum.PlayerAnimationModeSequence
case "S1":
return d2enum.PlayerAnimationModeSkill1
case "S2":
return d2enum.PlayerAnimationModeSkill1
case "S3":
return d2enum.PlayerAnimationModeSkill3
case "S4":
return d2enum.PlayerAnimationModeSkill4
case "A1":
return d2enum.PlayerAnimationModeAttack1
case "A2":
return d2enum.PlayerAnimationModeAttack2
case "":
return d2enum.PlayerAnimationModeNone
default:
log.Fatalf("Unknown skill anim value [%s]", anim)
}
// should not be reached
return d2enum.PlayerAnimationModeNone
}

View File

@ -1,6 +1,9 @@
package d2records
import "github.com/OpenDiablo2/OpenDiablo2/d2common/d2calculation"
import (
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2calculation"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum"
)
// [https://d2mods.info/forum/viewtopic.php?t=41556, https://d2mods.info/forum/kb/viewarticle?a=246]
@ -101,7 +104,7 @@ type SkillRecord struct {
Itypeb3 string
Etypeb1 string
Etypeb2 string
Anim string
Anim d2enum.PlayerAnimationMode
Seqtrans string
Monanim string
ItemCastSound string

View File

@ -194,6 +194,7 @@ func (v *Game) Render(screen d2interface.Surface) error {
if v.gameClient.RegenMap {
v.gameClient.RegenMap = false
v.mapRenderer.RegenerateTileCache()
v.gameClient.MapEngine.IsLoading = false
}
if err := screen.Clear(color.Black); err != nil {

View File

@ -68,8 +68,8 @@ type GameControls struct {
hpManaStatusSprite *d2ui.Sprite
mainPanel *d2ui.Sprite
menuButton *d2ui.Sprite
leftSkill *SkillResource
rightSkill *SkillResource
leftSkillResource *SkillResource
rightSkillResource *SkillResource
zoneChangeText *d2ui.Label
nameLabel *d2ui.Label
hpManaStatsLabel *d2ui.Label
@ -90,9 +90,12 @@ type ActionableRegion struct {
Rect d2geom.Rectangle
}
// SkillResource represents a Skill with its corresponding icon sprite, path to DC6 file and icon number.
// SkillResourcePath points to a DC6 resource which contains the icons of multiple skills as frames.
// The IconNumber is the frame at which we can find our skill sprite in the DC6 file.
type SkillResource struct {
SkillResourcePath string
IconNumber int
SkillResourcePath string // path to a skills DC6 file(see getSkillResourceByClass)
IconNumber int // the index of the frame in the DC6 file
SkillIcon *d2ui.Sprite
}
@ -230,7 +233,8 @@ func NewGameControls(
skillRecord := gc.asset.Records.Skill.Details[id]
skill, err := heroState.CreateHeroSkill(0, skillRecord.Skill)
if err != nil {
term.OutputErrorf("cannot create skill with ID of %d", id)
term.OutputErrorf("cannot create skill with ID of %d, error: %s", id, err)
return
}
gc.hero.LeftSkill = skill
@ -240,7 +244,8 @@ func NewGameControls(
skillRecord := gc.asset.Records.Skill.Details[id]
skill, err := heroState.CreateHeroSkill(0, skillRecord.Skill)
if err != nil {
term.OutputErrorf("cannot create skill with ID of %d", id)
term.OutputErrorf("cannot create skill with ID of %d, error: %s", id, err)
return
}
gc.hero.RightSkill = skill
@ -503,8 +508,8 @@ func (g *GameControls) Load() {
attackIconID := 2
g.leftSkill = &SkillResource{SkillIcon: genericSkillsSprite, IconNumber: attackIconID, SkillResourcePath: d2resource.GenericSkills}
g.rightSkill = &SkillResource{SkillIcon: genericSkillsSprite, IconNumber: attackIconID, SkillResourcePath: d2resource.GenericSkills}
g.leftSkillResource = &SkillResource{SkillIcon: genericSkillsSprite, IconNumber: attackIconID, SkillResourcePath: d2resource.GenericSkills}
g.rightSkillResource = &SkillResource{SkillIcon: genericSkillsSprite, IconNumber: attackIconID, SkillResourcePath: d2resource.GenericSkills}
g.loadUIButtons()
@ -688,19 +693,19 @@ func (g *GameControls) Render(target d2interface.Surface) error {
// Left skill
skillResourcePath := g.getSkillResourceByClass(g.hero.LeftSkill.Charclass)
if skillResourcePath != g.leftSkill.SkillResourcePath {
g.leftSkill.SkillIcon, _ = g.ui.NewSprite(skillResourcePath, d2resource.PaletteSky)
if skillResourcePath != g.leftSkillResource.SkillResourcePath {
g.leftSkillResource.SkillIcon, _ = g.ui.NewSprite(skillResourcePath, d2resource.PaletteSky)
}
if err := g.leftSkill.SkillIcon.SetCurrentFrame(g.hero.LeftSkill.IconCel); err != nil {
if err := g.leftSkillResource.SkillIcon.SetCurrentFrame(g.hero.LeftSkill.IconCel); err != nil {
return err
}
w, _ = g.leftSkill.SkillIcon.GetCurrentFrameSize()
w, _ = g.leftSkillResource.SkillIcon.GetCurrentFrameSize()
g.leftSkill.SkillIcon.SetPosition(offset, height)
g.leftSkillResource.SkillIcon.SetPosition(offset, height)
if err := g.leftSkill.SkillIcon.Render(target); err != nil {
if err := g.leftSkillResource.SkillIcon.Render(target); err != nil {
return err
}
@ -807,19 +812,19 @@ func (g *GameControls) Render(target d2interface.Surface) error {
// Right skill
skillResourcePath = g.getSkillResourceByClass(g.hero.RightSkill.Charclass)
if skillResourcePath != g.rightSkill.SkillResourcePath {
g.rightSkill.SkillIcon, _ = g.ui.NewSprite(skillResourcePath, d2resource.PaletteSky)
if skillResourcePath != g.rightSkillResource.SkillResourcePath {
g.rightSkillResource.SkillIcon, _ = g.ui.NewSprite(skillResourcePath, d2resource.PaletteSky)
}
if err := g.rightSkill.SkillIcon.SetCurrentFrame(g.hero.RightSkill.IconCel); err != nil {
if err := g.rightSkillResource.SkillIcon.SetCurrentFrame(g.hero.RightSkill.IconCel); err != nil {
return err
}
w, _ = g.rightSkill.SkillIcon.GetCurrentFrameSize()
w, _ = g.rightSkillResource.SkillIcon.GetCurrentFrameSize()
g.rightSkill.SkillIcon.SetPosition(offset, height)
g.rightSkillResource.SkillIcon.SetPosition(offset, height)
if err := g.rightSkill.SkillIcon.Render(target); err != nil {
if err := g.rightSkillResource.SkillIcon.Render(target); err != nil {
return err
}

View File

@ -198,6 +198,13 @@ func (r *RemoteClientConnection) decodeToPacket(t d2netpackettype.NetPacketType,
if err = json.Unmarshal([]byte(data), &p); err != nil {
break
}
np = d2netpacket.NetPacket{PacketType: t, PacketData: d2netpacket.MarshalPacket(p)}
case d2netpackettype.CastSkill:
var p d2netpacket.CastPacket
if err = json.Unmarshal([]byte(data), &p); err != nil {
break
}
np = d2netpacket.NetPacket{PacketType: t, PacketData: d2netpacket.MarshalPacket(p)}

View File

@ -17,6 +17,7 @@ import (
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2math/d2vector"
"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"
@ -56,6 +57,10 @@ func Create(connectionType d2clientconnectiontype.ClientConnectionType,
scriptEngine: scriptEngine,
}
// 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, result.MapEngine)
if err != nil {
return nil, err
@ -198,6 +203,8 @@ func (g *GameClient) handleAddPlayerPacket(packet d2netpacket.NetPacket) error {
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)
@ -268,42 +275,79 @@ func (g *GameClient) handleCastSkillPacket(packet d2netpacket.NetPacket) error {
castX := playerCast.TargetX * numSubtilesPerTile
castY := playerCast.TargetY * numSubtilesPerTile
rads := d2math.GetRadiansBetween(
direction := player.Position.DirectionTo(*d2vector.NewVector(castX, castY))
player.SetDirection(direction)
skillRecord := g.asset.Records.Skill.Details[playerCast.SkillID]
missileEntity, err := g.createMissileEntity(skillRecord, player, castX, castY)
if err != nil {
return err
}
player.StartCasting(skillRecord.Anim, func() {
if missileEntity != nil {
// shoot the missile after the player has finished casting
g.MapEngine.AddEntity(missileEntity)
}
})
overlayRecord := g.asset.Records.Layout.Overlays[skillRecord.Castoverlay]
g.playCastOverlay(overlayRecord, int(player.Position.X()), int(player.Position.Y()))
return nil
}
func (g *GameClient) createMissileEntity(skillRecord *d2records.SkillRecord, player *d2mapentity.Player, castX float64, castY float64) (*d2mapentity.Missile, error) {
missileRecord := g.asset.Records.GetMissileByName(skillRecord.Cltmissile)
if missileRecord == nil {
return nil, nil
}
var missileEntity *d2mapentity.Missile
radians := d2math.GetRadiansBetween(
player.Position.X(),
player.Position.Y(),
castX,
castY,
)
direction := player.Position.DirectionTo(*d2vector.NewVector(castX, castY))
player.SetDirection(direction)
skill := g.asset.Records.Skill.Details[playerCast.SkillID]
missileRecord := g.asset.Records.GetMissileByName(skill.Cltmissile)
missileEntity, err := g.MapEngine.NewMissile(
int(player.Position.X()),
int(player.Position.Y()),
g.asset.Records.Missiles[missileRecord.Id],
)
if missileRecord == nil {
//TODO: handle casts that have no missiles(or have multiple missiles and require additional logic)
log.Println("Missile not found for skill ID", skill.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 int, y int) error {
if overlayRecord == nil {
return nil
}
missile, err := g.MapEngine.NewMissile(
int(player.Position.X()),
int(player.Position.Y()),
missileRecord,
overlayEntity, err := g.MapEngine.NewCastOverlay(
x,
y,
overlayRecord,
)
if err != nil {
return err
}
missile.SetRadians(rads, func() {
g.MapEngine.RemoveEntity(missile)
overlayEntity.SetOnDoneFunc(func() {
g.MapEngine.RemoveEntity(overlayEntity)
})
player.StartCasting(func() {
// shoot the missile after the player finished casting
g.MapEngine.AddEntity(missile)
})
g.MapEngine.AddEntity(overlayEntity)
return nil
}

View File

@ -14,6 +14,7 @@ import (
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2asset"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2hero"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2map/d2mapengine"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2map/d2mapgen"
"github.com/OpenDiablo2/OpenDiablo2/d2networking/d2netpacket"
@ -181,6 +182,14 @@ func (g *GameServer) packetManager() {
}
g.sendPacketToClients(move)
case d2netpackettype.CastSkill:
castSkill, err := d2netpacket.UnmarshalNetPacket(p)
if err != nil {
log.Println(err)
continue
}
g.sendPacketToClients(castSkill)
case d2netpackettype.SpawnItem:
item, err := d2netpacket.UnmarshalNetPacket(p)
if err != nil {
@ -345,6 +354,8 @@ func handleClientConnection(gameServer *GameServer, client ClientConnection, x,
playerX := int(x*subtilesPerTile) + middleOfTileOffset
playerY := int(y*subtilesPerTile) + middleOfTileOffset
d2hero.HydrateSkills(playerState.Skills, gameServer.asset)
createPlayerPacket := d2netpacket.CreateAddPlayerPacket(
client.GetUniqueID(),
playerState.HeroName,
@ -430,6 +441,8 @@ func OnPacketReceived(client ClientConnection, packet d2netpacket.NetPacket) err
log.Printf("GameServer: error sending %T to client %s: %s", packet, player.GetUniqueID(), err)
}
}
default:
log.Printf("GameServer: received unknown packet %T", packet)
}
return nil