feat: add elite monsters with 5 prefix types

Elite monsters have ~20% spawn chance with Venomous, Burning, Freezing,
Bleeding, or Vampiric prefixes. Each prefix scales HP/ATK and applies
on-hit status effects (or life drain for Vampiric).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 15:02:44 +09:00
parent 8ef3d9dd13
commit 22ebeb1d48
5 changed files with 157 additions and 0 deletions

47
entity/elite.go Normal file
View File

@@ -0,0 +1,47 @@
package entity
import "math/rand"
type ElitePrefixType int
const (
PrefixVenomous ElitePrefixType = iota // poison on hit
PrefixBurning // burn on hit
PrefixFreezing // freeze on hit
PrefixBleeding // bleed on hit
PrefixVampiric // heals self on hit
)
type ElitePrefixDef struct {
Name string
HPMult float64
ATKMult float64
OnHit StatusEffect // -1 for vampiric (special)
}
// ElitePrefixDefs is exported so game/turn.go can access for on-hit effects.
var ElitePrefixDefs = map[ElitePrefixType]ElitePrefixDef{
PrefixVenomous: {"Venomous", 1.3, 1.0, StatusPoison},
PrefixBurning: {"Burning", 1.2, 1.1, StatusBurn},
PrefixFreezing: {"Freezing", 1.2, 1.0, StatusFreeze},
PrefixBleeding: {"Bleeding", 1.1, 1.2, StatusBleed},
PrefixVampiric: {"Vampiric", 1.4, 1.1, StatusEffect(-1)},
}
func (p ElitePrefixType) String() string {
return ElitePrefixDefs[p].Name
}
func RandomPrefix() ElitePrefixType {
return ElitePrefixType(rand.Intn(5))
}
func ApplyPrefix(m *Monster, prefix ElitePrefixType) {
def := ElitePrefixDefs[prefix]
m.IsElite = true
m.ElitePrefix = prefix
m.Name = def.Name + " " + m.Name
m.HP = int(float64(m.HP) * def.HPMult)
m.MaxHP = m.HP
m.ATK = int(float64(m.ATK) * def.ATKMult)
}

94
entity/elite_test.go Normal file
View File

@@ -0,0 +1,94 @@
package entity
import "testing"
func TestApplyPrefix(t *testing.T) {
m := &Monster{
Name: "Slime",
HP: 100,
MaxHP: 100,
ATK: 10,
DEF: 5,
}
ApplyPrefix(m, PrefixVenomous)
if !m.IsElite {
t.Fatal("expected IsElite to be true")
}
if m.ElitePrefix != PrefixVenomous {
t.Fatalf("expected PrefixVenomous, got %d", m.ElitePrefix)
}
if m.Name != "Venomous Slime" {
t.Fatalf("expected 'Venomous Slime', got %q", m.Name)
}
// HP should be multiplied by 1.3 => 130
if m.HP != 130 {
t.Fatalf("expected HP=130, got %d", m.HP)
}
if m.MaxHP != 130 {
t.Fatalf("expected MaxHP=130, got %d", m.MaxHP)
}
// ATK mult is 1.0 for Venomous, so ATK stays 10
if m.ATK != 10 {
t.Fatalf("expected ATK=10, got %d", m.ATK)
}
}
func TestApplyPrefixVampiric(t *testing.T) {
m := &Monster{
Name: "Orc",
HP: 100,
MaxHP: 100,
ATK: 20,
DEF: 5,
}
ApplyPrefix(m, PrefixVampiric)
if !m.IsElite {
t.Fatal("expected IsElite to be true")
}
if m.Name != "Vampiric Orc" {
t.Fatalf("expected 'Vampiric Orc', got %q", m.Name)
}
// HP * 1.4 = 140
if m.HP != 140 {
t.Fatalf("expected HP=140, got %d", m.HP)
}
// ATK * 1.1 = 22
if m.ATK != 22 {
t.Fatalf("expected ATK=22, got %d", m.ATK)
}
}
func TestRandomPrefix(t *testing.T) {
seen := make(map[ElitePrefixType]bool)
for i := 0; i < 200; i++ {
p := RandomPrefix()
if p < 0 || p > 4 {
t.Fatalf("RandomPrefix returned out-of-range value: %d", p)
}
seen[p] = true
}
// With 200 tries, all 5 prefixes should appear
if len(seen) != 5 {
t.Fatalf("expected all 5 prefixes to appear, got %d distinct values", len(seen))
}
}
func TestElitePrefixString(t *testing.T) {
if PrefixVenomous.String() != "Venomous" {
t.Fatalf("expected 'Venomous', got %q", PrefixVenomous.String())
}
if PrefixBurning.String() != "Burning" {
t.Fatalf("expected 'Burning', got %q", PrefixBurning.String())
}
if PrefixFreezing.String() != "Freezing" {
t.Fatalf("expected 'Freezing', got %q", PrefixFreezing.String())
}
if PrefixBleeding.String() != "Bleeding" {
t.Fatalf("expected 'Bleeding', got %q", PrefixBleeding.String())
}
if PrefixVampiric.String() != "Vampiric" {
t.Fatalf("expected 'Vampiric', got %q", PrefixVampiric.String())
}
}

View File

@@ -49,6 +49,8 @@ type Monster struct {
HP, MaxHP int HP, MaxHP int
ATK, DEF int ATK, DEF int
IsBoss bool IsBoss bool
IsElite bool
ElitePrefix ElitePrefixType
TauntTarget bool TauntTarget bool
TauntTurns int TauntTurns int
Pattern BossPattern Pattern BossPattern

View File

@@ -90,6 +90,9 @@ func (s *GameSession) spawnMonsters() {
m.MaxHP = m.HP m.MaxHP = m.HP
m.DEF = int(float64(m.DEF) * s.cfg.Combat.SoloHPReduction) m.DEF = int(float64(m.DEF) * s.cfg.Combat.SoloHPReduction)
} }
if rand.Float64() < 0.20 {
entity.ApplyPrefix(m, entity.RandomPrefix())
}
s.state.Monsters[i] = m s.state.Monsters[i] = m
} }

View File

@@ -379,6 +379,17 @@ func (s *GameSession) resolveMonsterActions() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0) dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
p.TakeDamage(dmg) p.TakeDamage(dmg)
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg)) s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg))
if m.IsElite {
def := entity.ElitePrefixDefs[m.ElitePrefix]
if def.OnHit >= 0 {
p.AddEffect(entity.ActiveEffect{Type: def.OnHit, Duration: 2, Value: 3})
s.addLog(fmt.Sprintf("%s's %s effect afflicts %s!", m.Name, def.Name, p.Name))
} else if m.ElitePrefix == entity.PrefixVampiric {
heal := dmg / 4
m.HP = min(m.HP+heal, m.MaxHP)
s.addLog(fmt.Sprintf("%s drains life from %s! (+%d HP)", m.Name, p.Name, heal))
}
}
if p.IsDead() { if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name)) s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
} }