Files
Catacombs/game/turn.go
tolelom b8697e414a feat: integrate skill tree UI and combat bonuses
Grant skill points on floor clear, add allocation UI with [ ] keys
during exploration, apply SkillPower bonus to Mage Fireball and Healer
Heal, initialize skills for new players, and deep copy skills in
GetState.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:47:01 +09:00

501 lines
13 KiB
Go

package game
import (
"fmt"
"math/rand"
"time"
"github.com/tolelom/catacombs/combat"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
)
func (s *GameSession) RunTurn() {
s.mu.Lock()
s.state.TurnNum++
s.state.CombatTurn++
s.clearLog()
s.actions = make(map[string]PlayerAction)
s.state.SubmittedActions = make(map[string]string)
aliveCount := 0
for _, p := range s.state.Players {
if !p.IsOut() {
aliveCount++
}
}
s.mu.Unlock()
// Collect actions with timeout
turnTimeout := time.Duration(s.cfg.Game.TurnTimeoutSec) * time.Second
timer := time.NewTimer(turnTimeout)
s.mu.Lock()
s.state.TurnDeadline = time.Now().Add(turnTimeout)
s.mu.Unlock()
collected := 0
collecting:
for collected < aliveCount {
select {
case msg := <-s.actionCh:
s.mu.Lock()
s.actions[msg.PlayerID] = msg.Action
s.mu.Unlock()
collected++
case <-timer.C:
break collecting
case <-s.done:
timer.Stop()
return
}
}
timer.Stop()
s.mu.Lock()
defer s.mu.Unlock()
s.state.TurnDeadline = time.Time{}
// Default action for players who didn't submit: Wait
for _, p := range s.state.Players {
if !p.IsOut() {
if _, ok := s.actions[p.Fingerprint]; !ok {
s.actions[p.Fingerprint] = PlayerAction{Type: ActionWait}
}
}
}
s.state.TurnResolving = true
s.state.PendingLogs = nil
s.resolvePlayerActions()
s.resolveMonsterActions()
s.state.TurnResolving = false
// PendingLogs now contains all turn results — UI will reveal them one by one via RevealNextLog
}
func (s *GameSession) resolvePlayerActions() {
// Tick status effects with floor theme damage bonus
theme := dungeon.GetTheme(s.state.FloorNum)
for _, p := range s.state.Players {
if !p.IsOut() {
// Snapshot effects before tick to compute theme bonus
effectsBefore := make([]entity.ActiveEffect, len(p.Effects))
copy(effectsBefore, p.Effects)
msgs := p.TickEffects()
for _, msg := range msgs {
s.addLog(msg)
}
// Apply theme damage bonus for matching status effects
for _, e := range effectsBefore {
if e.Value > 0 && (theme.StatusBoost == entity.StatusEffect(-1) || e.Type == theme.StatusBoost) {
bonus := int(float64(e.Value) * (theme.DamageMult - 1.0))
if bonus > 0 {
p.TakeDamage(bonus)
s.addLog(fmt.Sprintf(" (%s theme: +%d damage)", theme.Name, bonus))
}
}
}
if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}
}
}
var intents []combat.AttackIntent
var intentOwners []string // track who owns each intent
// Track which monsters were alive before this turn (for gold awards)
aliveBeforeTurn := make(map[int]bool)
for i, m := range s.state.Monsters {
if !m.IsDead() {
aliveBeforeTurn[i] = true
}
}
for _, p := range s.state.Players {
if p.IsOut() {
continue
}
action, ok := s.actions[p.Fingerprint]
if !ok {
continue
}
switch action.Type {
case ActionAttack:
intents = append(intents, combat.AttackIntent{
PlayerATK: p.EffectiveATK(),
TargetIdx: action.TargetIdx,
Multiplier: 1.0,
IsAoE: false,
})
intentOwners = append(intentOwners, p.Name)
case ActionSkill:
if p.SkillUses <= 0 {
s.addLog(fmt.Sprintf("%s has no skill uses left!", p.Name))
break
}
p.SkillUses--
switch p.Class {
case entity.ClassWarrior:
for _, m := range s.state.Monsters {
if !m.IsDead() {
m.TauntTarget = true
m.TauntTurns = 2
}
}
s.addLog(fmt.Sprintf("%s used Taunt! Enemies focus on %s for 2 turns", p.Name, p.Name))
case entity.ClassMage:
skillPower := 0
if p.Skills != nil {
skillPower = p.Skills.GetSkillPower(p.Class)
}
multiplier := 0.8 + float64(skillPower)/100.0
intents = append(intents, combat.AttackIntent{
PlayerATK: p.EffectiveATK(),
TargetIdx: -1,
Multiplier: multiplier,
IsAoE: true,
})
intentOwners = append(intentOwners, p.Name)
case entity.ClassHealer:
targetIdx := action.TargetIdx
if targetIdx < 0 || targetIdx >= len(s.state.Players) {
targetIdx = 0
}
target := s.state.Players[targetIdx]
if target.IsDead() {
// Find first alive player to heal instead
for j, candidate := range s.state.Players {
if !candidate.IsOut() {
target = candidate
targetIdx = j
break
}
}
}
healAmount := 30
if p.Skills != nil {
healAmount += p.Skills.GetSkillPower(p.Class) / 2
}
before := target.HP
target.Heal(healAmount)
s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before))
case entity.ClassRogue:
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
for _, neighborIdx := range currentRoom.Neighbors {
s.state.Floor.Rooms[neighborIdx].Visited = true
}
s.addLog(fmt.Sprintf("%s scouted nearby rooms!", p.Name))
}
case ActionItem:
found := false
for i, item := range p.Inventory {
if item.Type == entity.ItemConsumable {
before := p.HP
p.Heal(item.Bonus)
p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...)
s.addLog(fmt.Sprintf("%s used %s, restored %d HP", p.Name, item.Name, p.HP-before))
found = true
break
}
}
if !found {
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name))
}
case ActionFlee:
if combat.AttemptFlee(s.cfg.Combat.FleeChance) {
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
s.state.FleeSucceeded = true
if s.state.SoloMode {
s.state.Phase = PhaseExploring
return
}
p.Fled = true
} else {
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name))
}
case ActionWait:
s.addLog(fmt.Sprintf("%s is defending", p.Name))
}
}
// Check if all alive players have fled
allFled := true
for _, p := range s.state.Players {
if !p.IsDead() && !p.Fled {
allFled = false
break
}
}
if allFled && !s.state.SoloMode {
s.state.Phase = PhaseExploring
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
s.addLog("All players fled!")
for _, p := range s.state.Players {
p.Fled = false
}
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 {
owner := intentOwners[i]
if r.IsAoE {
coopStr := ""
if r.CoopApplied {
coopStr = " (co-op!)"
}
s.addLog(fmt.Sprintf("%s hit all enemies for %d total dmg%s", owner, r.Damage, coopStr))
} else if r.TargetIdx >= 0 && r.TargetIdx < len(s.state.Monsters) {
target := s.state.Monsters[r.TargetIdx]
coopStr := ""
if r.CoopApplied {
coopStr = " (co-op!)"
}
s.addLog(fmt.Sprintf("%s hit %s for %d dmg%s", owner, target.Name, r.Damage, coopStr))
}
}
}
// 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] {
goldReward := 5 + s.state.FloorNum*2
for _, p := range s.state.Players {
if !p.IsOut() {
bonus := 0
for _, r := range p.Relics {
if r.Effect == entity.RelicGoldBoost {
bonus += r.Value
}
if r.Effect == entity.RelicHealOnKill {
p.Heal(r.Value)
s.addLog(fmt.Sprintf("%s's relic heals %d HP", p.Name, r.Value))
}
}
p.Gold += goldReward + bonus
}
}
s.addLog(fmt.Sprintf("%s defeated! +%d gold", m.Name, goldReward))
if m.IsBoss {
s.state.BossKilled = true
s.grantBossRelic()
}
}
}
// Filter out dead monsters
alive := make([]*entity.Monster, 0)
for _, m := range s.state.Monsters {
if !m.IsDead() {
alive = append(alive, m)
}
}
s.state.Monsters = alive
// Check if combat is over
if len(s.state.Monsters) == 0 {
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
s.addLog("Room cleared!")
for _, p := range s.state.Players {
p.Fled = false
}
if s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Type == dungeon.RoomBoss {
s.advanceFloor()
} else {
s.state.Phase = PhaseExploring
}
}
}
func (s *GameSession) advanceFloor() {
if s.state.FloorNum >= s.cfg.Game.MaxFloors {
s.state.Phase = PhaseResult
s.state.Victory = true
s.state.GameOver = true
s.addLog("You conquered the Catacombs!")
return
}
// Grant 1 skill point per floor clear
for _, p := range s.state.Players {
if p.Skills == nil {
p.Skills = &entity.PlayerSkills{BranchIndex: -1}
}
p.Skills.Points++
}
s.state.FloorNum++
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano())))
s.state.Phase = PhaseExploring
s.state.CombatTurn = 0
s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum))
for _, p := range s.state.Players {
if p.IsDead() {
p.Revive(0.30)
s.addLog(fmt.Sprintf("✦ %s revived at %d HP!", p.Name, p.HP))
}
p.Fled = false
}
}
func (s *GameSession) grantBossRelic() {
relics := []entity.Relic{
{Name: "Vampiric Ring", Effect: entity.RelicHealOnKill, Value: 5, Price: 100},
{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() {
r := relics[rand.Intn(len(relics))]
p.Relics = append(p.Relics, r)
s.addLog(fmt.Sprintf("%s obtained relic: %s", p.Name, r.Name))
}
}
}
func (s *GameSession) resolveMonsterActions() {
if s.state.Phase != PhaseCombat {
return
}
for _, m := range s.state.Monsters {
if m.IsDead() {
continue
}
targetIdx, isAoE := combat.MonsterAI(m, s.state.Players, s.state.CombatTurn)
if isAoE {
for _, p := range s.state.Players {
if !p.IsOut() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5)
p.TakeDamage(dmg)
s.addLog(fmt.Sprintf("%s AoE hits %s for %d dmg", m.Name, p.Name, dmg))
if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}
}
}
if m.IsBoss || m.IsMiniBoss {
// Boss/mini-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.PatternFreeze:
for _, p := range s.state.Players {
if !p.IsOut() {
p.AddEffect(entity.ActiveEffect{Type: entity.StatusFreeze, Duration: 1, Value: 0})
s.addLog(fmt.Sprintf("%s freezes %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]
if !p.IsOut() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
p.TakeDamage(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() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name))
}
}
}
}
m.TickTaunt()
}
allPlayersDead := true
for _, p := range s.state.Players {
if !p.IsOut() {
allPlayersDead = false
break
}
}
if allPlayersDead {
s.state.Phase = PhaseResult
s.state.GameOver = true
s.addLog("Party wiped!")
}
}