feat: add skill tree system with 2 branches per class
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,7 @@ type Player struct {
|
|||||||
Dead bool
|
Dead bool
|
||||||
Fled bool
|
Fled bool
|
||||||
SkillUses int // remaining skill uses this combat
|
SkillUses int // remaining skill uses this combat
|
||||||
|
Skills *PlayerSkills
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlayer(name string, class Class) *Player {
|
func NewPlayer(name string, class Class) *Player {
|
||||||
@@ -118,6 +119,7 @@ func (p *Player) EffectiveATK() int {
|
|||||||
atk += r.Value
|
atk += r.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
atk += p.Skills.GetATKBonus(p.Class)
|
||||||
return atk
|
return atk
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +135,7 @@ func (p *Player) EffectiveDEF() int {
|
|||||||
def += r.Value
|
def += r.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
def += p.Skills.GetDEFBonus(p.Class)
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
196
entity/skill_tree.go
Normal file
196
entity/skill_tree.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// SkillEffect represents the type of bonus a skill node provides.
|
||||||
|
type SkillEffect int
|
||||||
|
|
||||||
|
const (
|
||||||
|
EffectATKBoost SkillEffect = iota
|
||||||
|
EffectDEFBoost
|
||||||
|
EffectMaxHPBoost
|
||||||
|
EffectSkillPower
|
||||||
|
EffectCritChance
|
||||||
|
EffectHealBoost
|
||||||
|
)
|
||||||
|
|
||||||
|
// SkillNode is a single node in a skill branch.
|
||||||
|
type SkillNode struct {
|
||||||
|
Name string
|
||||||
|
Effect SkillEffect
|
||||||
|
Value int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillBranch is a named sequence of 3 skill nodes.
|
||||||
|
type SkillBranch struct {
|
||||||
|
Name string
|
||||||
|
Nodes [3]SkillNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerSkills tracks a player's skill tree state for the current run.
|
||||||
|
type PlayerSkills struct {
|
||||||
|
BranchIndex int // -1 = not chosen, 0 or 1
|
||||||
|
Points int // total points earned (1 per floor clear)
|
||||||
|
Allocated int // points spent in chosen branch (max 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlayerSkills returns an initialized PlayerSkills with no branch chosen.
|
||||||
|
func NewPlayerSkills() *PlayerSkills {
|
||||||
|
return &PlayerSkills{BranchIndex: -1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// branchDefs holds 2 branches per class.
|
||||||
|
var branchDefs = map[Class][2]SkillBranch{
|
||||||
|
ClassWarrior: {
|
||||||
|
{
|
||||||
|
Name: "Tank",
|
||||||
|
Nodes: [3]SkillNode{
|
||||||
|
{"Iron Skin", EffectDEFBoost, 3},
|
||||||
|
{"Fortitude", EffectMaxHPBoost, 20},
|
||||||
|
{"Bastion", EffectDEFBoost, 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Berserker",
|
||||||
|
Nodes: [3]SkillNode{
|
||||||
|
{"Fury", EffectATKBoost, 4},
|
||||||
|
{"Wrath", EffectSkillPower, 20},
|
||||||
|
{"Rampage", EffectATKBoost, 6},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ClassMage: {
|
||||||
|
{
|
||||||
|
Name: "Elementalist",
|
||||||
|
Nodes: [3]SkillNode{
|
||||||
|
{"Arcane Focus", EffectSkillPower, 15},
|
||||||
|
{"Elemental Fury", EffectATKBoost, 5},
|
||||||
|
{"Overload", EffectSkillPower, 25},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Chronomancer",
|
||||||
|
Nodes: [3]SkillNode{
|
||||||
|
{"Temporal Shield", EffectDEFBoost, 3},
|
||||||
|
{"Time Warp", EffectATKBoost, 3},
|
||||||
|
{"Stasis", EffectMaxHPBoost, 15},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ClassHealer: {
|
||||||
|
{
|
||||||
|
Name: "Guardian",
|
||||||
|
Nodes: [3]SkillNode{
|
||||||
|
{"Blessing", EffectHealBoost, 20},
|
||||||
|
{"Divine Armor", EffectDEFBoost, 4},
|
||||||
|
{"Miracle", EffectHealBoost, 30},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Priest",
|
||||||
|
Nodes: [3]SkillNode{
|
||||||
|
{"Smite", EffectATKBoost, 5},
|
||||||
|
{"Holy Power", EffectSkillPower, 20},
|
||||||
|
{"Judgment", EffectATKBoost, 7},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ClassRogue: {
|
||||||
|
{
|
||||||
|
Name: "Assassin",
|
||||||
|
Nodes: [3]SkillNode{
|
||||||
|
{"Backstab", EffectATKBoost, 5},
|
||||||
|
{"Precision", EffectCritChance, 15},
|
||||||
|
{"Execute", EffectATKBoost, 8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Alchemist",
|
||||||
|
Nodes: [3]SkillNode{
|
||||||
|
{"Tonic", EffectHealBoost, 15},
|
||||||
|
{"Brew", EffectSkillPower, 20},
|
||||||
|
{"Elixir", EffectMaxHPBoost, 25},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBranches returns the 2 skill branches for the given class.
|
||||||
|
func GetBranches(class Class) [2]SkillBranch {
|
||||||
|
return branchDefs[class]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate spends one skill point into the given branch. Returns an error if
|
||||||
|
// the player tries to switch branches after first allocation or has already
|
||||||
|
// allocated the maximum of 3 points.
|
||||||
|
func (ps *PlayerSkills) Allocate(branchIdx int, class Class) error {
|
||||||
|
if ps == nil {
|
||||||
|
return errors.New("skills not initialized")
|
||||||
|
}
|
||||||
|
if branchIdx < 0 || branchIdx > 1 {
|
||||||
|
return errors.New("invalid branch index")
|
||||||
|
}
|
||||||
|
if ps.Allocated >= 3 {
|
||||||
|
return errors.New("branch fully allocated")
|
||||||
|
}
|
||||||
|
if ps.Points <= ps.Allocated {
|
||||||
|
return errors.New("no available skill points")
|
||||||
|
}
|
||||||
|
if ps.BranchIndex != -1 && ps.BranchIndex != branchIdx {
|
||||||
|
return errors.New("cannot switch branch after first allocation")
|
||||||
|
}
|
||||||
|
ps.BranchIndex = branchIdx
|
||||||
|
ps.Allocated++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocatedNodes returns the slice of nodes the player has unlocked.
|
||||||
|
func (ps *PlayerSkills) allocatedNodes(class Class) []SkillNode {
|
||||||
|
if ps == nil || ps.BranchIndex < 0 || ps.Allocated == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
branches := GetBranches(class)
|
||||||
|
branch := branches[ps.BranchIndex]
|
||||||
|
return branch.Nodes[:ps.Allocated]
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumEffect sums values of nodes matching the given effect.
|
||||||
|
func (ps *PlayerSkills) sumEffect(class Class, effect SkillEffect) int {
|
||||||
|
total := 0
|
||||||
|
for _, node := range ps.allocatedNodes(class) {
|
||||||
|
if node.Effect == effect {
|
||||||
|
total += node.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetATKBonus returns the total ATK bonus from allocated skill nodes.
|
||||||
|
func (ps *PlayerSkills) GetATKBonus(class Class) int {
|
||||||
|
return ps.sumEffect(class, EffectATKBoost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDEFBonus returns the total DEF bonus from allocated skill nodes.
|
||||||
|
func (ps *PlayerSkills) GetDEFBonus(class Class) int {
|
||||||
|
return ps.sumEffect(class, EffectDEFBoost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxHPBonus returns the total MaxHP bonus from allocated skill nodes.
|
||||||
|
func (ps *PlayerSkills) GetMaxHPBonus(class Class) int {
|
||||||
|
return ps.sumEffect(class, EffectMaxHPBoost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSkillPower returns the total SkillPower bonus from allocated skill nodes.
|
||||||
|
func (ps *PlayerSkills) GetSkillPower(class Class) int {
|
||||||
|
return ps.sumEffect(class, EffectSkillPower)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCritChance returns the total CritChance bonus from allocated skill nodes.
|
||||||
|
func (ps *PlayerSkills) GetCritChance(class Class) int {
|
||||||
|
return ps.sumEffect(class, EffectCritChance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHealBoost returns the total HealBoost bonus from allocated skill nodes.
|
||||||
|
func (ps *PlayerSkills) GetHealBoost(class Class) int {
|
||||||
|
return ps.sumEffect(class, EffectHealBoost)
|
||||||
|
}
|
||||||
148
entity/skill_tree_test.go
Normal file
148
entity/skill_tree_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGetBranches(t *testing.T) {
|
||||||
|
classes := []Class{ClassWarrior, ClassMage, ClassHealer, ClassRogue}
|
||||||
|
for _, c := range classes {
|
||||||
|
branches := GetBranches(c)
|
||||||
|
if len(branches) != 2 {
|
||||||
|
t.Errorf("expected 2 branches for %s, got %d", c, len(branches))
|
||||||
|
}
|
||||||
|
for i, b := range branches {
|
||||||
|
if b.Name == "" {
|
||||||
|
t.Errorf("branch %d for %s has empty name", i, c)
|
||||||
|
}
|
||||||
|
for j, node := range b.Nodes {
|
||||||
|
if node.Name == "" {
|
||||||
|
t.Errorf("node %d in branch %d for %s has empty name", j, i, c)
|
||||||
|
}
|
||||||
|
if node.Value <= 0 {
|
||||||
|
t.Errorf("node %d in branch %d for %s has non-positive value %d", j, i, c, node.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllocateSkillPoint(t *testing.T) {
|
||||||
|
ps := NewPlayerSkills()
|
||||||
|
ps.Points = 1
|
||||||
|
|
||||||
|
err := ps.Allocate(0, ClassWarrior)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error on first allocation: %v", err)
|
||||||
|
}
|
||||||
|
if ps.BranchIndex != 0 {
|
||||||
|
t.Errorf("expected BranchIndex 0, got %d", ps.BranchIndex)
|
||||||
|
}
|
||||||
|
if ps.Allocated != 1 {
|
||||||
|
t.Errorf("expected Allocated 1, got %d", ps.Allocated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotSwitchBranch(t *testing.T) {
|
||||||
|
ps := NewPlayerSkills()
|
||||||
|
ps.Points = 2
|
||||||
|
|
||||||
|
err := ps.Allocate(0, ClassWarrior)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ps.Allocate(1, ClassWarrior)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when switching branch, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotAllocateWithoutPoints(t *testing.T) {
|
||||||
|
ps := NewPlayerSkills()
|
||||||
|
ps.Points = 0
|
||||||
|
|
||||||
|
err := ps.Allocate(0, ClassWarrior)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no points available, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullyAllocated(t *testing.T) {
|
||||||
|
ps := NewPlayerSkills()
|
||||||
|
ps.Points = 4
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
err := ps.Allocate(0, ClassWarrior)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error on allocation %d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ps.Allocate(0, ClassWarrior)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when fully allocated, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillBonuses(t *testing.T) {
|
||||||
|
// Warrior Tank branch: DEF+3, MaxHP+20, DEF+5
|
||||||
|
ps := NewPlayerSkills()
|
||||||
|
ps.Points = 3
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := ps.Allocate(0, ClassWarrior); err != nil {
|
||||||
|
t.Fatalf("allocate error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ps.GetDEFBonus(ClassWarrior); got != 8 {
|
||||||
|
t.Errorf("expected DEF bonus 8 (3+5), got %d", got)
|
||||||
|
}
|
||||||
|
if got := ps.GetMaxHPBonus(ClassWarrior); got != 20 {
|
||||||
|
t.Errorf("expected MaxHP bonus 20, got %d", got)
|
||||||
|
}
|
||||||
|
if got := ps.GetATKBonus(ClassWarrior); got != 0 {
|
||||||
|
t.Errorf("expected ATK bonus 0 for Tank, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warrior Berserker branch: ATK+4, SkillPower+20, ATK+6
|
||||||
|
ps2 := NewPlayerSkills()
|
||||||
|
ps2.Points = 3
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := ps2.Allocate(1, ClassWarrior); err != nil {
|
||||||
|
t.Fatalf("allocate error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := ps2.GetATKBonus(ClassWarrior); got != 10 {
|
||||||
|
t.Errorf("expected ATK bonus 10 (4+6), got %d", got)
|
||||||
|
}
|
||||||
|
if got := ps2.GetSkillPower(ClassWarrior); got != 20 {
|
||||||
|
t.Errorf("expected SkillPower bonus 20, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNilPlayerSkillsBonuses(t *testing.T) {
|
||||||
|
var ps *PlayerSkills
|
||||||
|
if got := ps.GetATKBonus(ClassWarrior); got != 0 {
|
||||||
|
t.Errorf("expected 0 ATK bonus from nil skills, got %d", got)
|
||||||
|
}
|
||||||
|
if got := ps.GetDEFBonus(ClassWarrior); got != 0 {
|
||||||
|
t.Errorf("expected 0 DEF bonus from nil skills, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialAllocation(t *testing.T) {
|
||||||
|
// Rogue Assassin: ATK+5, CritChance+15, ATK+8
|
||||||
|
// Allocate only 2 points: should get ATK+5 and CritChance+15
|
||||||
|
ps := NewPlayerSkills()
|
||||||
|
ps.Points = 2
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := ps.Allocate(0, ClassRogue); err != nil {
|
||||||
|
t.Fatalf("allocate error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := ps.GetATKBonus(ClassRogue); got != 5 {
|
||||||
|
t.Errorf("expected ATK bonus 5, got %d", got)
|
||||||
|
}
|
||||||
|
if got := ps.GetCritChance(ClassRogue); got != 15 {
|
||||||
|
t.Errorf("expected CritChance bonus 15, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user