feat: add combo skill system with 5 combos
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
91
combat/combo.go
Normal file
91
combat/combo.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package combat
|
||||
|
||||
import "github.com/tolelom/catacombs/entity"
|
||||
|
||||
type ComboAction struct {
|
||||
Class entity.Class
|
||||
ActionType string // "attack", "skill", "item"
|
||||
}
|
||||
|
||||
type ComboEffect struct {
|
||||
DamageMultiplier float64 // multiplied onto each AttackIntent.Multiplier
|
||||
BonusDamage int // added to each AttackIntent.PlayerATK
|
||||
HealAll int // heal all players after resolution
|
||||
Message string // shown in combat log
|
||||
}
|
||||
|
||||
type ComboDef struct {
|
||||
Name string
|
||||
Required []ComboAction
|
||||
Effect ComboEffect
|
||||
}
|
||||
|
||||
var comboDefs = []ComboDef{
|
||||
{
|
||||
Name: "Ice Shatter",
|
||||
Required: []ComboAction{
|
||||
{Class: entity.ClassMage, ActionType: "skill"},
|
||||
{Class: entity.ClassWarrior, ActionType: "attack"},
|
||||
},
|
||||
Effect: ComboEffect{DamageMultiplier: 1.5, Message: "💥 ICE SHATTER! Frozen enemies shatter!"},
|
||||
},
|
||||
{
|
||||
Name: "Holy Assault",
|
||||
Required: []ComboAction{
|
||||
{Class: entity.ClassHealer, ActionType: "skill"},
|
||||
{Class: entity.ClassWarrior, ActionType: "attack"},
|
||||
},
|
||||
Effect: ComboEffect{DamageMultiplier: 1.3, HealAll: 10, Message: "✨ HOLY ASSAULT! Blessed strikes heal the party!"},
|
||||
},
|
||||
{
|
||||
Name: "Shadow Strike",
|
||||
Required: []ComboAction{
|
||||
{Class: entity.ClassRogue, ActionType: "skill"},
|
||||
{Class: entity.ClassMage, ActionType: "attack"},
|
||||
},
|
||||
Effect: ComboEffect{DamageMultiplier: 1.4, Message: "🗡️ SHADOW STRIKE! Magical shadows amplify the attack!"},
|
||||
},
|
||||
{
|
||||
Name: "Full Assault",
|
||||
Required: []ComboAction{
|
||||
{Class: entity.ClassWarrior, ActionType: "attack"},
|
||||
{Class: entity.ClassMage, ActionType: "attack"},
|
||||
{Class: entity.ClassRogue, ActionType: "attack"},
|
||||
},
|
||||
Effect: ComboEffect{DamageMultiplier: 1.3, BonusDamage: 5, Message: "⚔️ FULL ASSAULT! Combined attack overwhelms!"},
|
||||
},
|
||||
{
|
||||
Name: "Restoration",
|
||||
Required: []ComboAction{
|
||||
{Class: entity.ClassHealer, ActionType: "skill"},
|
||||
{Class: entity.ClassRogue, ActionType: "item"},
|
||||
},
|
||||
Effect: ComboEffect{HealAll: 20, Message: "💚 RESTORATION! Combined healing surges!"},
|
||||
},
|
||||
}
|
||||
|
||||
func DetectCombos(actions map[string]ComboAction) []ComboDef {
|
||||
var triggered []ComboDef
|
||||
for _, combo := range comboDefs {
|
||||
if matchesCombo(combo.Required, actions) {
|
||||
triggered = append(triggered, combo)
|
||||
}
|
||||
}
|
||||
return triggered
|
||||
}
|
||||
|
||||
func matchesCombo(required []ComboAction, actions map[string]ComboAction) bool {
|
||||
for _, req := range required {
|
||||
found := false
|
||||
for _, act := range actions {
|
||||
if act.Class == req.Class && act.ActionType == req.ActionType {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
45
combat/combo_test.go
Normal file
45
combat/combo_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package combat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tolelom/catacombs/entity"
|
||||
)
|
||||
|
||||
func TestDetectCombo_IceShatter(t *testing.T) {
|
||||
actions := map[string]ComboAction{
|
||||
"mage": {Class: entity.ClassMage, ActionType: "skill"},
|
||||
"warrior": {Class: entity.ClassWarrior, ActionType: "attack"},
|
||||
}
|
||||
combos := DetectCombos(actions)
|
||||
if len(combos) == 0 {
|
||||
t.Fatal("expected Ice Shatter combo")
|
||||
}
|
||||
if combos[0].Name != "Ice Shatter" {
|
||||
t.Errorf("expected 'Ice Shatter', got %q", combos[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCombo_NoMatch(t *testing.T) {
|
||||
actions := map[string]ComboAction{
|
||||
"w1": {Class: entity.ClassWarrior, ActionType: "attack"},
|
||||
"w2": {Class: entity.ClassWarrior, ActionType: "attack"},
|
||||
}
|
||||
combos := DetectCombos(actions)
|
||||
if len(combos) != 0 {
|
||||
t.Errorf("expected no combos, got %d", len(combos))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCombo_Multiple(t *testing.T) {
|
||||
// Healer skill + Warrior attack + Rogue item → Holy Assault + Restoration
|
||||
actions := map[string]ComboAction{
|
||||
"healer": {Class: entity.ClassHealer, ActionType: "skill"},
|
||||
"warrior": {Class: entity.ClassWarrior, ActionType: "attack"},
|
||||
"rogue": {Class: entity.ClassRogue, ActionType: "item"},
|
||||
}
|
||||
combos := DetectCombos(actions)
|
||||
if len(combos) != 2 {
|
||||
t.Errorf("expected 2 combos, got %d", len(combos))
|
||||
}
|
||||
}
|
||||
46
game/turn.go
46
game/turn.go
@@ -213,6 +213,41 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
return
|
||||
}
|
||||
|
||||
// Combo detection: build action map and apply combo effects before resolving attacks
|
||||
comboActions := make(map[string]combat.ComboAction)
|
||||
for _, p := range s.state.Players {
|
||||
if p.IsOut() {
|
||||
continue
|
||||
}
|
||||
action, ok := s.actions[p.Fingerprint]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var actionType string
|
||||
switch action.Type {
|
||||
case ActionAttack:
|
||||
actionType = "attack"
|
||||
case ActionSkill:
|
||||
actionType = "skill"
|
||||
case ActionItem:
|
||||
actionType = "item"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
comboActions[p.Fingerprint] = combat.ComboAction{Class: p.Class, ActionType: actionType}
|
||||
}
|
||||
|
||||
combos := combat.DetectCombos(comboActions)
|
||||
for _, combo := range combos {
|
||||
s.addLog(combo.Effect.Message)
|
||||
for i := range intents {
|
||||
if combo.Effect.DamageMultiplier > 0 {
|
||||
intents[i].Multiplier *= combo.Effect.DamageMultiplier
|
||||
}
|
||||
intents[i].PlayerATK += combo.Effect.BonusDamage
|
||||
}
|
||||
}
|
||||
|
||||
if len(intents) > 0 && len(s.state.Monsters) > 0 {
|
||||
results := combat.ResolveAttacks(intents, s.state.Monsters, s.cfg.Game.CoopBonus)
|
||||
for i, r := range results {
|
||||
@@ -234,6 +269,17 @@ func (s *GameSession) resolvePlayerActions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply combo HealAll effects after attack resolution
|
||||
for _, combo := range combos {
|
||||
if combo.Effect.HealAll > 0 {
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsOut() {
|
||||
p.Heal(combo.Effect.HealAll)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Award gold only for monsters that JUST died this turn
|
||||
for i, m := range s.state.Monsters {
|
||||
if m.IsDead() && aliveBeforeTurn[i] {
|
||||
|
||||
Reference in New Issue
Block a user