diff --git a/d2common/d2enum/skill_class.go b/d2common/d2enum/skill_class.go new file mode 100644 index 00000000..1c6208d3 --- /dev/null +++ b/d2common/d2enum/skill_class.go @@ -0,0 +1,73 @@ +package d2enum + +import "log" + +type SkillClass int + +const ( + // SkillClassGeneric is "" + SkillClassGeneric SkillClass = iota + SkillClassBarbarian + SkillClassNecromancer + SkillClassPaladin + SkillClassAssassin + SkillClassSorceress + SkillClassAmazon + SkillClassDruid +) + +// FromToken returns the enum which corresponds to the given class token +func (sc *SkillClass) FromToken(classToken string) SkillClass { + resource := SkillClassGeneric + + switch classToken { + case "": + return SkillClassGeneric + case "bar": + return SkillClassBarbarian + case "nec": + return SkillClassNecromancer + case "pal": + return SkillClassPaladin + case "ass": + return SkillClassAssassin + case "sor": + return SkillClassSorceress + case "ama": + return SkillClassAmazon + case "dru": + return SkillClassDruid + default: + log.Fatalf("Unknown skill class token: '%s'", classToken) + } + + // should not be reached + return resource +} + +// GetToken returns a string token for the enum +func (sc SkillClass) GetToken() string { + switch sc { + case SkillClassGeneric: + return "" + case SkillClassBarbarian: + return "bar" + case SkillClassNecromancer: + return "nec" + case SkillClassPaladin: + return "pal" + case SkillClassAssassin: + return "ass" + case SkillClassSorceress: + return "sor" + case SkillClassAmazon: + return "ama" + case SkillClassDruid: + return "dru" + default: + log.Fatalf("Unknown skill class token: %v", sc) + } + + // should not be reached + return "" +} diff --git a/d2core/d2asset/composite.go b/d2core/d2asset/composite.go index 1323d360..23047bb4 100644 --- a/d2core/d2asset/composite.go +++ b/d2core/d2asset/composite.go @@ -98,6 +98,16 @@ func (c *Composite) GetAnimationMode() string { return c.mode.animationMode.String() } +// GetCurrentFrame returns the frame index in the current animation mode. +func (c *Composite) GetCurrentFrame() int { + return c.mode.frameIndex +} + +// GetFrameCount returns the number of frames in the current animation mode. +func (c *Composite) GetFrameCount() int { + return c.mode.frameCount +} + // GetWeaponClass returns the currently loaded weapon class func (c *Composite) GetWeaponClass() string { return c.mode.weaponClass diff --git a/d2core/d2hero/hero_skill_util.go b/d2core/d2hero/hero_skill_util.go index 81c28262..f840a624 100644 --- a/d2core/d2hero/hero_skill_util.go +++ b/d2core/d2hero/hero_skill_util.go @@ -6,16 +6,8 @@ import "github.com/OpenDiablo2/OpenDiablo2/d2core/d2asset" // 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] - - // TODO: figure out why these are nil sometimes - if heroSkill == nil { - continue - } - - heroSkill.SkillRecord = asset.Records.Skill.Details[skillID] - heroSkill.SkillDescriptionRecord = asset.Records.Skill.Descriptions[heroSkill.SkillRecord.Skilldesc] - heroSkill.SkillPoints = skills[skillID].SkillPoints + for skillID, skill := range skills { + skill.SkillRecord = asset.Records.Skill.Details[skillID] + skill.SkillDescriptionRecord = asset.Records.Skill.Descriptions[skill.SkillRecord.Skilldesc] } } diff --git a/d2core/d2hero/hero_state_factory.go b/d2core/d2hero/hero_state_factory.go index 5ecd46f4..5310825d 100644 --- a/d2core/d2hero/hero_state_factory.go +++ b/d2core/d2hero/hero_state_factory.go @@ -158,6 +158,7 @@ func (f *HeroStateFactory) CreateHeroSkill(points int, name string) (*HeroSkill, SkillPoints: points, SkillRecord: skillRecord, SkillDescriptionRecord: skillDescRecord, + shallow: &shallowHeroSkill{SkillID: skillRecord.ID, SkillPoints: points}, } return result, nil diff --git a/d2core/d2map/d2mapentity/factory.go b/d2core/d2map/d2mapentity/factory.go index b2da0e45..81b6a4fc 100644 --- a/d2core/d2map/d2mapentity/factory.go +++ b/d2core/d2map/d2mapentity/factory.go @@ -102,7 +102,8 @@ func (f *MapEntityFactory) NewPlayer(id, name string, x, y, direction int, heroT } result.mapEntity.uuid = id - result.SetSpeed(baseRunSpeed) + //TODO: should be based on Player.isRunning after we store isRunning in the save file + result.SetSpeed(baseWalkSpeed) result.mapEntity.directioner = result.rotate err = composite.SetMode(d2enum.PlayerAnimationModeTownNeutral, equipment.RightHand.GetWeaponClass()) diff --git a/d2core/d2map/d2mapentity/player.go b/d2core/d2map/d2mapentity/player.go index da7566ee..2421369d 100644 --- a/d2core/d2map/d2mapentity/player.go +++ b/d2core/d2map/d2mapentity/player.go @@ -83,15 +83,19 @@ func (p *Player) IsInTown() bool { func (p *Player) Advance(tickTime float64) { p.Step(tickTime) - if p.IsCasting() && p.composite.GetPlayedCount() >= 1 { - p.isCasting = false - if p.onFinishedCasting != nil { - p.onFinishedCasting() - p.onFinishedCasting = nil + if p.IsCasting() { + if p.composite.GetPlayedCount() >= 1 { + p.isCasting = false + if err := p.SetAnimationMode(p.GetAnimationMode()); err != nil { + fmt.Printf("failed to set animationMode to: %d, err: %v\n", p.GetAnimationMode(), err) + } } - if err := p.SetAnimationMode(p.GetAnimationMode()); err != nil { - fmt.Printf("failed to set animationMode to: %d, err: %v\n", p.GetAnimationMode(), err) + // skills are casted after the first half of the casting animation is played + isHalfDoneCasting := float64(p.composite.GetCurrentFrame()) / float64(p.composite.GetFrameCount()) >= 0.5 + if isHalfDoneCasting && p.onFinishedCasting != nil { + p.onFinishedCasting() + p.onFinishedCasting = nil } } diff --git a/d2core/d2records/skill_description_loader.go b/d2core/d2records/skill_description_loader.go index 917cbf8f..94394f3d 100644 --- a/d2core/d2records/skill_description_loader.go +++ b/d2core/d2records/skill_description_loader.go @@ -21,7 +21,7 @@ func skillDescriptionLoader(r *RecordManager, d *d2txt.DataDictionary) error { d.Number("SkillPage"), d.Number("SkillRow"), d.Number("SkillColumn"), - d.String("ListRow"), + d.Number("ListRow"), d.String("ListPool"), d.Number("IconCel"), d.String("str name"), diff --git a/d2core/d2records/skill_description_record.go b/d2core/d2records/skill_description_record.go index bb43b3b1..e343a334 100644 --- a/d2core/d2records/skill_description_record.go +++ b/d2core/d2records/skill_description_record.go @@ -12,7 +12,7 @@ type SkillDescriptionRecord struct { SkillPage int // SkillPage SkillRow int // SkillRow SkillColumn int // SkillColumn - ListRow string // ListRow + ListRow int // ListRow ListPool string // ListPool IconCel int // IconCel NameKey string // str name diff --git a/d2core/d2records/skill_details_loader.go b/d2core/d2records/skill_details_loader.go index 363c958f..57321387 100644 --- a/d2core/d2records/skill_details_loader.go +++ b/d2core/d2records/skill_details_loader.go @@ -295,7 +295,7 @@ func animToEnum(anim string) d2enum.PlayerAnimationMode { return d2enum.PlayerAnimationModeSkill1 case "S2": - return d2enum.PlayerAnimationModeSkill1 + return d2enum.PlayerAnimationModeSkill2 case "S3": return d2enum.PlayerAnimationModeSkill3 diff --git a/d2game/d2player/game_controls.go b/d2game/d2player/game_controls.go index 0ad88c20..bf973a99 100644 --- a/d2game/d2player/game_controls.go +++ b/d2game/d2player/game_controls.go @@ -69,6 +69,7 @@ type GameControls struct { hpManaStatusSprite *d2ui.Sprite mainPanel *d2ui.Sprite menuButton *d2ui.Sprite + skillSelectMenu *SkillSelectMenu leftSkillResource *SkillResource rightSkillResource *SkillResource zoneChangeText *d2ui.Label @@ -192,6 +193,7 @@ func NewGameControls( inputListener: inputListener, mapRenderer: mapRenderer, inventory: NewInventory(asset, ui, inventoryRecord), + skillSelectMenu: NewSkillSelectMenu(asset, ui, hero), skilltree: NewSkillTree(hero.Skills, hero.Class, asset, renderer, ui, guiManager), heroStatsPanel: NewHeroStatsPanel(asset, ui, hero.Name(), hero.Class, hero.Stats), HelpOverlay: help.NewHelpOverlay(asset, renderer, ui, guiManager), @@ -223,35 +225,7 @@ func NewGameControls( isSinglePlayer: isSinglePlayer, } - err = term.BindAction("freecam", "toggle free camera movement", func() { - gc.FreeCam = !gc.FreeCam - }) - - if err != nil { - return nil, err - } - - err = term.BindAction("setleftskill", "set skill to fire on left click", func(id int) { - 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, error: %s", id, err) - return - } - - gc.hero.LeftSkill = skill - }) - - err = term.BindAction("setrightskill", "set skill to fire on right click", func(id int) { - 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, error: %s", id, err) - return - } - - gc.hero.RightSkill = skill - }) + gc.bindTerminalCommands(term) if err != nil { return nil, err @@ -346,6 +320,10 @@ func (g *GameControls) OnKeyUp(event d2interface.KeyEvent) bool { func (g *GameControls) onEscKey() { escHandled := false + if g.skillSelectMenu.IsOpen() { + g.skillSelectMenu.ClosePanels() + escHandled = true + } if g.inventory.IsOpen() { g.inventory.Close() @@ -447,6 +425,9 @@ func (g *GameControls) OnMouseMove(event d2interface.MouseMoveEvent) bool { } } + g.skillSelectMenu.LeftPanel.HandleMouseMove(mx, my) + g.skillSelectMenu.RightPanel.HandleMouseMove(mx, my) + return false } @@ -462,6 +443,13 @@ func (g *GameControls) OnMouseButtonDown(event d2interface.MouseEvent) bool { } } + if g.skillSelectMenu.IsOpen() && event.Button() == d2enum.MouseButtonLeft { + g.lastLeftBtnActionTime = d2util.Now() + g.skillSelectMenu.HandleClick(mx, my) + g.skillSelectMenu.ClosePanels() + return false + } + px, py := g.mapRenderer.ScreenToWorld(mx, my) px = float64(int(px*10)) / 10.0 py = float64(int(py*10)) / 10.0 @@ -616,11 +604,15 @@ func (g *GameControls) isInActiveMenusRect(px, py int) bool { return true } + if g.skillSelectMenu.IsOpen() { + return true + } + return false } -// TODO: consider caching the panels to single image that is reused. // Render draws the GameControls onto the target +// TODO: consider caching the panels to single image that is reused. func (g *GameControls) Render(target d2interface.Surface) error { mx, my := g.lastMouseX, g.lastMouseY @@ -714,9 +706,10 @@ func (g *GameControls) Render(target d2interface.Surface) error { offset += w // Left skill - skillResourcePath := g.getSkillResourceByClass(g.hero.LeftSkill.Charclass) - if skillResourcePath != g.leftSkillResource.SkillResourcePath { - g.leftSkillResource.SkillIcon, _ = g.ui.NewSprite(skillResourcePath, d2resource.PaletteSky) + newSkillResourcePath := g.getSkillResourceByClass(g.hero.LeftSkill.Charclass) + if newSkillResourcePath != g.leftSkillResource.SkillResourcePath { + g.leftSkillResource.SkillResourcePath = newSkillResourcePath + g.leftSkillResource.SkillIcon, _ = g.ui.NewSprite(newSkillResourcePath, d2resource.PaletteSky) } if err := g.leftSkillResource.SkillIcon.SetCurrentFrame(g.hero.LeftSkill.IconCel); err != nil { @@ -833,9 +826,10 @@ func (g *GameControls) Render(target d2interface.Surface) error { offset += w // Right skill - skillResourcePath = g.getSkillResourceByClass(g.hero.RightSkill.Charclass) - if skillResourcePath != g.rightSkillResource.SkillResourcePath { - g.rightSkillResource.SkillIcon, _ = g.ui.NewSprite(skillResourcePath, d2resource.PaletteSky) + newSkillResourcePath = g.getSkillResourceByClass(g.hero.RightSkill.Charclass) + if newSkillResourcePath != g.rightSkillResource.SkillResourcePath { + g.rightSkillResource.SkillIcon, _ = g.ui.NewSprite(newSkillResourcePath, d2resource.PaletteSky) + g.rightSkillResource.SkillResourcePath = newSkillResourcePath } if err := g.rightSkillResource.SkillIcon.SetCurrentFrame(g.hero.RightSkill.IconCel); err != nil { @@ -1033,6 +1027,10 @@ func (g *GameControls) Render(target d2interface.Surface) error { g.nameLabel.Render(target) } + if g.skillSelectMenu.IsOpen() { + g.skillSelectMenu.Render(target) + } + return nil } @@ -1119,7 +1117,7 @@ func (g *GameControls) onHoverActionable(item actionableType) { func (g *GameControls) onClickActionable(item actionableType) { switch item { case leftSkill: - log.Println("Left Skill Action Pressed") + g.skillSelectMenu.ToggleLeftPanel() case newStats: log.Println("New Stats Selector Action Pressed") case xp: @@ -1135,7 +1133,7 @@ func (g *GameControls) onClickActionable(item actionableType) { case newSkills: log.Println("New Skills Selector Action Pressed") case rightSkill: - log.Println("Right Skill Action Pressed") + g.skillSelectMenu.ToggleRightPanel() case hpGlobe: g.ToggleHpStats() log.Println("HP Globe Pressed") @@ -1165,6 +1163,106 @@ func (g *GameControls) onClickActionable(item actionableType) { } } +func (g *GameControls) bindTerminalCommands(term d2interface.Terminal) error { + err := term.BindAction("freecam", "toggle free camera movement", func() { + g.FreeCam = !g.FreeCam + }) + + if err != nil { + return err + } + + err = term.BindAction("setleftskill", "set skill to fire on left click", func(id int) { + skillRecord := g.asset.Records.Skill.Details[id] + skill, err := g.heroState.CreateHeroSkill(1, skillRecord.Skill) + if err != nil { + term.OutputErrorf("cannot create skill with ID of %d, error: %s", id, err) + return + } + + g.hero.LeftSkill = skill + }) + + err = term.BindAction("learnskills", "learn all skills for the a given class", func(token string) { + if len(token) < 3 { + term.OutputErrorf("The given class token should be at least 3 characters") + return + } + validPrefixes := []string{"ama", "ass", "nec", "bar", "sor", "dru", "pal"} + classToken := strings.ToLower(token) + tokenPrefix := classToken[0:3] + isValidToken := false + + for idx := range validPrefixes { + if strings.Compare(tokenPrefix, validPrefixes[idx]) == 0 { + isValidToken = true + } + } + + if !isValidToken { + term.OutputErrorf("Invalid class, must be a value starting with(case insensitive): %s", strings.Join(validPrefixes, ", ")) + return + } + + learnedSkillsCount := 0 + for _, skillDetailRecord := range g.asset.Records.Skill.Details { + if skillDetailRecord.Charclass == classToken || skillDetailRecord.Charclass == "" { + skill, err := g.heroState.CreateHeroSkill(1, skillDetailRecord.Skill) + if skill == nil { + continue + } + + learnedSkillsCount++ + g.hero.Skills[skill.ID] = skill + + if err != nil { + break + } + } + } + g.skillSelectMenu.RegenerateImageCache() + log.Printf("Learned %d skills", learnedSkillsCount) + + if err != nil { + term.OutputErrorf("cannot learn skill for class, error: %s", err) + return + } + }) + + err = term.BindAction("setrightskill", "set skill to fire on right click", func(id int) { + skillRecord := g.asset.Records.Skill.Details[id] + skill, err := g.heroState.CreateHeroSkill(0, skillRecord.Skill) + if err != nil { + term.OutputErrorf("cannot create skill with ID of %d, error: %s", id, err) + return + } + + g.hero.RightSkill = skill + }) + + err = term.BindAction("learnskillid", "learn a skill by a given ID", func(id int) { + skillRecord := g.asset.Records.Skill.Details[id] + if skillRecord == nil { + term.OutputErrorf("cannot find a skill record for ID: %d", id) + return + } + + skill, err := g.heroState.CreateHeroSkill(1, skillRecord.Skill) + + g.hero.Skills[skill.ID] = skill + + if err != nil { + term.OutputErrorf("cannot learn skill for class, error: %s", err) + return + } + + g.skillSelectMenu.RegenerateImageCache() + log.Println("Learned skill: ", skill.Skill) + }) + + return nil +} + func (g *GameControls) getSkillResourceByClass(class string) string { resource := "" diff --git a/d2game/d2player/skill_row.go b/d2game/d2player/skill_row.go new file mode 100644 index 00000000..2ff79b8f --- /dev/null +++ b/d2game/d2player/skill_row.go @@ -0,0 +1,35 @@ +package d2player + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2geom" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2hero" +) + +// SkillListRow represents a row of skills that is shown when the skill select menu is rendered. +type SkillListRow struct { + Rectangle d2geom.Rectangle + Skills []*d2hero.HeroSkill + cachedImage d2interface.Surface +} + +// AddSkill appends to the skills of the row. +func (s *SkillListRow) AddSkill(skill *d2hero.HeroSkill) { + s.Skills = append(s.Skills, skill) +} + +// GetWidth returns the width based on the size of the skills. +func (s *SkillListRow) GetWidth() int { + return skillIconWidth * len(s.Skills) +} + +// GetRectangle returns the rectangle of the list. +func (s *SkillListRow) GetRectangle() d2geom.Rectangle { + return s.Rectangle +} + +// IsInRect returns true when the list has any skills and coordinates are in the rectangle of the list. +func (s *SkillListRow) IsInRect(X int, Y int) bool { + // if there are no skills, row won't be rendered and it shouldn't be considered visible + return len(s.Skills) > 0 && s.Rectangle.IsInRect(X, Y) +} diff --git a/d2game/d2player/skill_select_menu.go b/d2game/d2player/skill_select_menu.go new file mode 100644 index 00000000..193b0b93 --- /dev/null +++ b/d2game/d2player/skill_select_menu.go @@ -0,0 +1,106 @@ +package d2player + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2asset" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2map/d2mapentity" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2ui" +) + +// SkillSelectMenu is a wrapper for the left + right menu that pop up when a player clicks the left/right skill select. +type SkillSelectMenu struct { + LeftPanel *SkillPanel + RightPanel *SkillPanel +} + +// NewSkillSelectMenu creates a skill select menu. +func NewSkillSelectMenu(asset *d2asset.AssetManager, ui *d2ui.UIManager, hero *d2mapentity.Player) *SkillSelectMenu { + skillSelectMenu := &SkillSelectMenu{ + LeftPanel: NewHeroSkillsPanel(asset, ui, hero, true), + RightPanel: NewHeroSkillsPanel(asset, ui, hero, false), + } + + return skillSelectMenu +} + +// HandleClick will propagate the click to the panels. +func (sm *SkillSelectMenu) HandleClick(X int, Y int) bool { + if sm.LeftPanel.HandleClick(X, Y) { + return true + } + + if sm.RightPanel.HandleClick(X, Y) { + return true + } + + return true +} + +// HandleMouseMove will propagate the mouse move event to the panels. +func (sm *SkillSelectMenu) HandleMouseMove(X int, Y int) { + if sm.LeftPanel.IsOpen() { + sm.LeftPanel.HandleMouseMove(X, Y) + + } else if sm.RightPanel.IsOpen() { + sm.RightPanel.HandleMouseMove(X, Y) + } +} + +// RegenerateImageCache will force both panels to re-create the image shown at skill popup menus. +// Somewhat expensive operation, should not be called often. +func (sm *SkillSelectMenu) RegenerateImageCache() { + sm.LeftPanel.RegenerateImageCache() + sm.RightPanel.RegenerateImageCache() +} + +// Render gets called on every frame +func (sm *SkillSelectMenu) Render(target d2interface.Surface) { + sm.LeftPanel.Render(target) + sm.RightPanel.Render(target) +} + +// IsOpen returns whether one of the panels(left or right) is open +func (sm *SkillSelectMenu) IsOpen() bool { + return sm.LeftPanel.IsOpen() || sm.RightPanel.IsOpen() +} + +// IsInRect returns whether the coordinates are in one of the panels(left or right) +func (sm *SkillSelectMenu) IsInRect(X int, Y int) bool { + return sm.LeftPanel.IsInRect(X, Y) || sm.RightPanel.IsInRect(X, Y) +} + +// ClosePanels will close both panels +func (sm *SkillSelectMenu) ClosePanels() { + sm.RightPanel.Close() + sm.LeftPanel.Close() +} + +// OpenLeftPanel will close the right panel and open the left panel. +func (sm *SkillSelectMenu) OpenLeftPanel() { + sm.RightPanel.Close() + sm.LeftPanel.Open() +} + +// ToggleLeftPanel will close or open the left panel, depending on the current state +func (sm *SkillSelectMenu) ToggleLeftPanel() { + if sm.LeftPanel.IsOpen() { + sm.LeftPanel.Close() + } else { + sm.OpenLeftPanel() + } +} + +// OpenRightPanel will close the left panel and open the right panel. +func (sm *SkillSelectMenu) OpenRightPanel() { + sm.LeftPanel.Close() + sm.RightPanel.Open() +} + +// ToggleRightPanel will close or open the right panel, depending on the current state +func (sm *SkillSelectMenu) ToggleRightPanel() { + if sm.RightPanel.IsOpen() { + sm.RightPanel.Close() + } else { + sm.OpenRightPanel() + } +} diff --git a/d2game/d2player/skill_select_panel.go b/d2game/d2player/skill_select_panel.go new file mode 100644 index 00000000..de8f9966 --- /dev/null +++ b/d2game/d2player/skill_select_panel.go @@ -0,0 +1,372 @@ +package d2player + +import ( + "fmt" + "log" + "sort" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2fileformats/d2tbl" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2geom" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2resource" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2asset" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2gui" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2hero" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2map/d2mapentity" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2ui" +) + +const ( + skillIconWidth = 48 + screenWidth = 800 + skillIconHeight = 48 + rightPanelEndX = 720 + leftPanelStartX = 90 + skillPanelOffsetY = 465 + skillListsLength = 5 // 0 to 4. 0 - General Skills, 1 to 3 - Class-specific skills(based on the 3 different skill trees), 4 - Other skills +) + +// SkillPanel represents a skill select menu popup that is displayed when the player left clicks on his active left/right skill. +type SkillPanel struct { + asset *d2asset.AssetManager + activeSkill *d2hero.HeroSkill + isOpen bool + regenerateImageCache bool + hero *d2mapentity.Player + ListRows []*SkillListRow + isLeftPanel bool + renderer d2interface.Renderer + ui *d2ui.UIManager + hoveredSkill *d2hero.HeroSkill + hoverTooltipPos d2geom.Point + //TODO: should be a cached image which contains the skill text + the tooltip background + hoverTooltipText *d2ui.Label +} + +// NewHeroSkillsPanel creates a new hero status panel +func NewHeroSkillsPanel(asset *d2asset.AssetManager, ui *d2ui.UIManager, hero *d2mapentity.Player, isLeftPanel bool) *SkillPanel { + var activeSkill *d2hero.HeroSkill + if isLeftPanel { + activeSkill = hero.LeftSkill + } else { + activeSkill = hero.RightSkill + } + + hoverTooltipText := ui.NewLabel(d2resource.Font16, d2resource.PaletteStatic) + hoverTooltipText.Alignment = d2gui.HorizontalAlignCenter + + return &SkillPanel{ + asset: asset, + activeSkill: activeSkill, + ui: ui, + isOpen: false, + ListRows: make([]*SkillListRow, skillListsLength), + renderer: ui.Renderer(), + isLeftPanel: isLeftPanel, + hero: hero, + hoverTooltipText: hoverTooltipText, + } +} + +// Open opens the hero skills panel +func (s *SkillPanel) Open() { + s.isOpen = true + s.regenerateImageCache = true +} + +// Close the hero skills panel +func (s *SkillPanel) Close() { + s.isOpen = false +} + +// IsInRect returns whether the X Y coordinates are in some of the list rows of the panel. +func (s *SkillPanel) IsInRect(X int, Y int) bool { + for _, listRow := range s.ListRows { + + // TODO: investigate why listRow can be nil + if listRow != nil && listRow.IsInRect(X, Y) { + return true + } + } + + return false +} + +// GetListRowByPos returns the skill list row for a given X and Y, based on the width and height of the skills list. +func (s *SkillPanel) GetListRowByPos(X int, Y int) *SkillListRow { + for _, listRow := range s.ListRows { + if listRow.IsInRect(X, Y) { + return listRow + } + } + + return nil +} + +// Render gets called on every tick +func (s *SkillPanel) Render(target d2interface.Surface) error { + if !s.isOpen { + return nil + } + + if s.regenerateImageCache { + s.generateSkillRowImageCache(target) + s.regenerateImageCache = false + } + + renderedRows := 0 + for _, skillListRow := range s.ListRows { + if len(skillListRow.Skills) == 0 { + continue + } + + startX := s.getRowStartX(skillListRow) + rowOffsetY := skillPanelOffsetY - (renderedRows * skillIconHeight) + + target.PushTranslation(startX, rowOffsetY) + target.Render(skillListRow.cachedImage) + target.Pop() + + renderedRows++ + } + + if s.hoveredSkill != nil { + target.PushTranslation(s.hoverTooltipPos.X, s.hoverTooltipPos.Y) + s.hoverTooltipText.Render(target) + target.Pop() + } + + return nil +} + +// RegenerateImageCache will force re-generating the cached menu image on next Render. +// Somewhat expensive operation, should not be called often. Currently called every time the panel is opened or when the player learns a new skill. +func (s *SkillPanel) RegenerateImageCache() { + s.regenerateImageCache = true +} + +// IsOpen returns true if the hero skills panel is open +func (s *SkillPanel) IsOpen() bool { + return s.isOpen +} + +// Toggle toggles the visibility of the hero status panel +func (s *SkillPanel) Toggle() { + if s.isOpen { + s.Close() + } else { + s.Open() + } +} + +func (s *SkillPanel) generateSkillRowImageCache(target d2interface.Surface) error { + for idx := range s.ListRows { + s.ListRows[idx] = &SkillListRow{Skills: make([]*d2hero.HeroSkill, 0), Rectangle: d2geom.Rectangle{Height: 0, Width: 0}} + } + + for _, skill := range s.hero.Skills { + // left panel with an incompatible skill(e.g. Paladin auras cant be used as a left skill) + if s.isLeftPanel && !skill.Leftskill { + continue + } + + // ListRow is -1 for other skills that should not be shown in the panel(e.g. Kick) + if skill.ListRow == -1 || skill.Passive { + continue + } + + s.ListRows[skill.ListRow].AddSkill(skill) + } + + visibleRows := 0 + for idx, skillListRow := range s.ListRows { + // row won't be considered as visible + if len(skillListRow.Skills) == 0 { + continue + } + + skillListRow.Rectangle = d2geom.Rectangle{ + Height: skillIconHeight, + Width: skillListRow.GetWidth(), + Left: s.getRowStartX(skillListRow), + Top: skillPanelOffsetY - (visibleRows * skillIconHeight), + } + + sort.SliceStable(skillListRow.Skills, func(a, b int) bool { + // left panel skills are aligned by ID (low to high), right panel is the opposite + if s.isLeftPanel { + return skillListRow.Skills[a].ID < skillListRow.Skills[b].ID + } + + return skillListRow.Skills[a].ID > skillListRow.Skills[b].ID + }) + + cachedImage, err := s.createSkillListImage(skillListRow) + + if err != nil { + log.Println(err) + return err + } + + s.ListRows[idx].cachedImage = cachedImage + visibleRows++ + } + + return nil +} + +func (s *SkillPanel) createSkillListImage(skillsListRow *SkillListRow) (d2interface.Surface, error) { + surface, err := s.renderer.NewSurface(len(skillsListRow.Skills)*skillIconWidth, skillIconHeight, d2enum.FilterNearest) + + if err != nil { + return nil, err + } + + lastSkillResourcePath := d2resource.GenericSkills + skillSprite, _ := s.ui.NewSprite(s.getSkillResourceByClass(""), d2resource.PaletteSky) + for idx, skill := range skillsListRow.Skills { + currentResourcePath := s.getSkillResourceByClass(skill.Charclass) + // only load a new sprite if the DCC file path changed + if currentResourcePath != lastSkillResourcePath { + lastSkillResourcePath = currentResourcePath + skillSprite, _ = s.ui.NewSprite(currentResourcePath, d2resource.PaletteSky) + } + + if skillSprite.GetFrameCount() <= skill.IconCel { + // happens for non-player skills, since they do not have an icon + log.Printf("Invalid IconCel(sprite frame index) [%d] - Skill name: %s, skipping.", skill.IconCel, skill.Name) + continue + } + + if err := skillSprite.SetCurrentFrame(skill.IconCel); err != nil { + return nil, err + } + + surface.PushTranslation(idx*skillIconWidth, 50) + + if err := skillSprite.Render(surface); err != nil { + return nil, err + } + surface.Pop() + } + + return surface, nil +} + +func (s *SkillPanel) getRowStartX(skillRow *SkillListRow) int { + if s.isLeftPanel { + return leftPanelStartX + } + + // for the right panel, we only know where it should end, so we calculate the start based on the width of the list row + return rightPanelEndX - skillRow.GetWidth() +} + +func (s *SkillPanel) getSkillAtPos(X int, Y int) *d2hero.HeroSkill { + listRow := s.GetListRowByPos(X, Y) + + if listRow == nil { + return nil + } + + skillIndex := (X - s.getRowStartX(listRow)) / skillIconWidth + skill := listRow.Skills[skillIndex] + + return skill +} + +func (s *SkillPanel) getSkillIdxAtPos(X int, Y int) int { + listRow := s.GetListRowByPos(X, Y) + + if listRow == nil { + return -1 + } + + skillIndex := (X - s.getRowStartX(listRow)) / skillIconWidth + + return skillIndex +} + +// HandleClick will change the hero's active(left or right) skill and return true. Returns false if the given X, Y is out of panel boundaries. +func (s *SkillPanel) HandleClick(X int, Y int) bool { + if !s.isOpen || !s.IsInRect(X, Y) { + return false + } + + clickedSkill := s.getSkillAtPos(X, Y) + + if clickedSkill == nil { + return false + } + + if s.isLeftPanel { + s.hero.LeftSkill = clickedSkill + } else { + s.hero.RightSkill = clickedSkill + } + + return true +} + +// HandleMouseMove will process a mouse move event, if inside the panel. +func (s *SkillPanel) HandleMouseMove(X int, Y int) bool { + if !s.isOpen { + return false + } + + if !s.IsInRect(X, Y) { + // panel still open but player hovered outside panel - hide the previously hovered skill(if any) + s.hoveredSkill = nil + return false + } + + previousHovered := s.hoveredSkill + s.hoveredSkill = s.getSkillAtPos(X, Y) + + if previousHovered != s.hoveredSkill && s.hoveredSkill != nil { + skillDescription := d2tbl.TranslateString(s.hoveredSkill.ShortKey) + //TODO: should generate a cached image for the tooltip instead + s.hoverTooltipText.SetText(fmt.Sprintf("%s\n%s", s.hoveredSkill.Skill, skillDescription)) + + listRow := s.GetListRowByPos(X, Y) + tooltipWidth, _ := s.hoverTooltipText.GetSize() + tooltipX := (s.getSkillIdxAtPos(X, Y) * skillIconWidth) + s.getRowStartX(listRow) + (tooltipWidth / 2) + if tooltipX+tooltipWidth >= screenWidth { + tooltipX = screenWidth - (tooltipWidth / 2) + } + + tooltipY := listRow.Rectangle.Top + listRow.Rectangle.Height + + s.hoverTooltipPos = d2geom.Point{X: tooltipX, Y: tooltipY} + } + + return true +} + +func (s *SkillPanel) getSkillResourceByClass(class string) string { + resource := "" + + switch class { + case "": + resource = d2resource.GenericSkills + case "bar": + resource = d2resource.BarbarianSkills + case "nec": + resource = d2resource.NecromancerSkills + case "pal": + resource = d2resource.PaladinSkills + case "ass": + resource = d2resource.AssassinSkills + case "sor": + resource = d2resource.SorcererSkills + case "ama": + resource = d2resource.AmazonSkills + case "dru": + resource = d2resource.DruidSkills + default: + log.Fatalf("Unknown class token: '%s'", class) + } + + return resource +}