feat: status effects (poison/burn), boss patterns, new relics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 14:49:55 +09:00
parent 533e460968
commit d3d7e2a76a
8 changed files with 158 additions and 2 deletions

View File

@@ -82,8 +82,8 @@ func AttemptFlee() bool {
}
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
if m.IsBoss && turnNumber%3 == 0 {
return -1, true
if m.IsBoss && turnNumber > 0 && turnNumber%3 == 0 {
return -1, true // AoE every 3 turns for all bosses
}
if m.TauntTarget {
for i, p := range players {

View File

@@ -22,6 +22,9 @@ const (
RelicATKBoost
RelicDEFBoost
RelicGoldBoost
RelicPoisonImmunity // immune to poison
RelicBurnResist // halve burn damage
RelicLifeSteal // heal 10% of damage dealt
)
type Relic struct {

View File

@@ -33,6 +33,16 @@ var monsterDefs = map[MonsterType]monsterBase{
MonsterBoss20: {"Archlich", 600, 40, 20, 20, true},
}
type BossPattern int
const (
PatternNone BossPattern = iota
PatternAoE // every 3 turns AoE
PatternPoison // applies poison
PatternBurn // applies burn to random player
PatternHeal // heals self
)
type Monster struct {
Name string
Type MonsterType
@@ -41,6 +51,7 @@ type Monster struct {
IsBoss bool
TauntTarget bool
TauntTurns int
Pattern BossPattern
}
func NewMonster(mt MonsterType, floor int) *Monster {

View File

@@ -1,5 +1,7 @@
package entity
import "fmt"
type Class int
const (
@@ -24,6 +26,20 @@ var classBaseStats = map[Class]classStats{
ClassRogue: {85, 15, 4},
}
type StatusEffect int
const (
StatusPoison StatusEffect = iota
StatusBurn
StatusFreeze
)
type ActiveEffect struct {
Type StatusEffect
Duration int // remaining turns
Value int // damage per turn or effect strength
}
type Player struct {
Name string
Fingerprint string
@@ -33,6 +49,7 @@ type Player struct {
Gold int
Inventory []Item
Relics []Relic
Effects []ActiveEffect
Dead bool
Fled bool
SkillUses int // remaining skill uses this combat
@@ -110,3 +127,59 @@ func (p *Player) EffectiveDEF() int {
}
return def
}
func (p *Player) AddEffect(e ActiveEffect) {
// Check relic immunities
for _, r := range p.Relics {
if e.Type == StatusPoison && r.Effect == RelicPoisonImmunity {
return // immune
}
if e.Type == StatusBurn && r.Effect == RelicBurnResist {
e.Value = e.Value / 2 // halve burn damage
}
}
// Don't stack same type, refresh duration
for i, existing := range p.Effects {
if existing.Type == e.Type {
p.Effects[i] = e
return
}
}
p.Effects = append(p.Effects, e)
}
func (p *Player) HasEffect(t StatusEffect) bool {
for _, e := range p.Effects {
if e.Type == t {
return true
}
}
return false
}
func (p *Player) TickEffects() (damages []string) {
var remaining []ActiveEffect
for _, e := range p.Effects {
switch e.Type {
case StatusPoison:
p.HP -= e.Value
if p.HP <= 0 {
p.HP = 1 // Poison can't kill, leaves at 1 HP
}
damages = append(damages, fmt.Sprintf("%s takes %d poison damage", p.Name, e.Value))
case StatusBurn:
p.HP -= e.Value
if p.HP <= 0 {
p.HP = 0
p.Dead = true
}
damages = append(damages, fmt.Sprintf("%s takes %d burn damage", p.Name, e.Value))
}
e.Duration--
if e.Duration > 0 {
remaining = append(remaining, e)
}
}
p.Effects = remaining
return
}

View File

@@ -114,6 +114,16 @@ func (s *GameSession) spawnBoss() {
mt = entity.MonsterBoss5
}
boss := entity.NewMonster(mt, s.state.FloorNum)
switch mt {
case entity.MonsterBoss5:
boss.Pattern = entity.PatternAoE
case entity.MonsterBoss10:
boss.Pattern = entity.PatternPoison
case entity.MonsterBoss15:
boss.Pattern = entity.PatternBurn
case entity.MonsterBoss20:
boss.Pattern = entity.PatternHeal
}
if s.state.SoloMode {
boss.HP = boss.HP / 2
boss.MaxHP = boss.HP

View File

@@ -220,6 +220,8 @@ func (s *GameSession) GetState() GameState {
copy(cp.Inventory, p.Inventory)
cp.Relics = make([]entity.Relic, len(p.Relics))
copy(cp.Relics, p.Relics)
cp.Effects = make([]entity.ActiveEffect, len(p.Effects))
copy(cp.Effects, p.Effects)
players[i] = &cp
}

View File

@@ -73,6 +73,19 @@ collecting:
}
func (s *GameSession) resolvePlayerActions() {
// Tick status effects
for _, p := range s.state.Players {
if !p.IsOut() {
msgs := p.TickEffects()
for _, msg := range msgs {
s.addLog(msg)
}
if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}
}
}
var intents []combat.AttackIntent
var intentOwners []string // track who owns each intent
@@ -299,6 +312,9 @@ func (s *GameSession) grantBossRelic() {
{Name: "Power Amulet", Effect: entity.RelicATKBoost, Value: 3, Price: 120},
{Name: "Iron Ward", Effect: entity.RelicDEFBoost, Value: 2, Price: 100},
{Name: "Gold Charm", Effect: entity.RelicGoldBoost, Value: 5, Price: 150},
{Name: "Antidote Charm", Effect: entity.RelicPoisonImmunity, Value: 0, Price: 100},
{Name: "Flame Guard", Effect: entity.RelicBurnResist, Value: 0, Price: 120},
{Name: "Life Siphon", Effect: entity.RelicLifeSteal, Value: 10, Price: 150},
}
for _, p := range s.state.Players {
if !p.IsOut() {
@@ -329,6 +345,32 @@ func (s *GameSession) resolveMonsterActions() {
}
}
}
if m.IsBoss {
// Boss special pattern
switch m.Pattern {
case entity.PatternPoison:
for _, p := range s.state.Players {
if !p.IsOut() {
p.AddEffect(entity.ActiveEffect{Type: entity.StatusPoison, Duration: 3, Value: 5})
s.addLog(fmt.Sprintf("%s poisons %s!", m.Name, p.Name))
}
}
case entity.PatternBurn:
for _, p := range s.state.Players {
if !p.IsOut() {
p.AddEffect(entity.ActiveEffect{Type: entity.StatusBurn, Duration: 2, Value: 8})
s.addLog(fmt.Sprintf("%s burns %s!", m.Name, p.Name))
}
}
case entity.PatternHeal:
healAmt := m.MaxHP / 10
m.HP += healAmt
if m.HP > m.MaxHP {
m.HP = m.MaxHP
}
s.addLog(fmt.Sprintf("%s regenerates %d HP!", m.Name, healAmt))
}
}
} else {
if targetIdx >= 0 && targetIdx < len(s.state.Players) {
p := s.state.Players[targetIdx]

View File

@@ -248,6 +248,21 @@ func renderPartyPanel(players []*entity.Player, submittedActions map[string]stri
hpBar := renderHPBar(p.HP, p.MaxHP, 16)
sb.WriteString(fmt.Sprintf(" %s %d/%d\n", hpBar, p.HP, p.MaxHP))
if len(p.Effects) > 0 {
var effects []string
for _, e := range p.Effects {
switch e.Type {
case entity.StatusPoison:
effects = append(effects, styleHeal.Render(fmt.Sprintf("☠Poison(%dt)", e.Duration)))
case entity.StatusBurn:
effects = append(effects, styleDamage.Render(fmt.Sprintf("🔥Burn(%dt)", e.Duration)))
case entity.StatusFreeze:
effects = append(effects, styleFlee.Render(fmt.Sprintf("❄Freeze(%dt)", e.Duration)))
}
}
sb.WriteString(" " + strings.Join(effects, " ") + "\n")
}
sb.WriteString(fmt.Sprintf(" ATK:%-3d DEF:%-3d ", p.EffectiveATK(), p.EffectiveDEF()))
sb.WriteString(styleGold.Render(fmt.Sprintf("G:%d", p.Gold)))
sb.WriteString("\n")