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:
2026-03-25 14:36:19 +09:00
parent 05cf59c659
commit 8ef3d9dd13
3 changed files with 347 additions and 0 deletions

View File

@@ -55,6 +55,7 @@ type Player struct {
Dead bool
Fled bool
SkillUses int // remaining skill uses this combat
Skills *PlayerSkills
}
func NewPlayer(name string, class Class) *Player {
@@ -118,6 +119,7 @@ func (p *Player) EffectiveATK() int {
atk += r.Value
}
}
atk += p.Skills.GetATKBonus(p.Class)
return atk
}
@@ -133,6 +135,7 @@ func (p *Player) EffectiveDEF() int {
def += r.Value
}
}
def += p.Skills.GetDEFBonus(p.Class)
return def
}

196
entity/skill_tree.go Normal file
View 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
View 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)
}
}