272 lines
6.2 KiB
Go
272 lines
6.2 KiB
Go
package game
|
|
|
|
import (
|
|
"math/rand"
|
|
"time"
|
|
|
|
"github.com/tolelom/catacombs/combat"
|
|
"github.com/tolelom/catacombs/dungeon"
|
|
"github.com/tolelom/catacombs/entity"
|
|
)
|
|
|
|
const TurnTimeout = 5 * time.Second
|
|
|
|
func (s *GameSession) RunTurn() {
|
|
s.mu.Lock()
|
|
s.state.TurnNum++
|
|
s.state.CombatTurn++
|
|
s.actions = make(map[string]PlayerAction)
|
|
aliveCount := 0
|
|
for _, p := range s.state.Players {
|
|
if !p.IsDead() {
|
|
aliveCount++
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
// Collect actions with timeout
|
|
timer := time.NewTimer(TurnTimeout)
|
|
collected := 0
|
|
for collected < aliveCount {
|
|
select {
|
|
case msg := <-s.actionCh:
|
|
s.mu.Lock()
|
|
s.actions[msg.PlayerName] = msg.Action
|
|
s.mu.Unlock()
|
|
collected++
|
|
case <-timer.C:
|
|
goto resolve
|
|
}
|
|
}
|
|
timer.Stop()
|
|
|
|
resolve:
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Default action for players who didn't submit: Wait
|
|
for _, p := range s.state.Players {
|
|
if !p.IsDead() {
|
|
if _, ok := s.actions[p.Name]; !ok {
|
|
s.actions[p.Name] = PlayerAction{Type: ActionWait}
|
|
}
|
|
}
|
|
}
|
|
|
|
s.resolvePlayerActions()
|
|
s.resolveMonsterActions()
|
|
}
|
|
|
|
func (s *GameSession) resolvePlayerActions() {
|
|
var intents []combat.AttackIntent
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Check if ALL alive players chose flee — only then the party flees
|
|
fleeCount := 0
|
|
aliveCount := 0
|
|
for _, p := range s.state.Players {
|
|
if p.IsDead() {
|
|
continue
|
|
}
|
|
aliveCount++
|
|
if action, ok := s.actions[p.Name]; ok && action.Type == ActionFlee {
|
|
fleeCount++
|
|
}
|
|
}
|
|
if fleeCount == aliveCount && aliveCount > 0 {
|
|
if combat.AttemptFlee() {
|
|
s.state.Phase = PhaseExploring
|
|
return
|
|
}
|
|
// Flee failed — all fleeing players waste their turn, continue to monster phase
|
|
return
|
|
}
|
|
|
|
for _, p := range s.state.Players {
|
|
if p.IsDead() {
|
|
continue
|
|
}
|
|
action, ok := s.actions[p.Name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
switch action.Type {
|
|
case ActionAttack:
|
|
intents = append(intents, combat.AttackIntent{
|
|
PlayerATK: p.EffectiveATK(),
|
|
TargetIdx: action.TargetIdx,
|
|
Multiplier: 1.0,
|
|
IsAoE: false,
|
|
})
|
|
case ActionSkill:
|
|
switch p.Class {
|
|
case entity.ClassWarrior:
|
|
// Taunt: mark all monsters to target this warrior
|
|
for _, m := range s.state.Monsters {
|
|
if !m.IsDead() {
|
|
m.TauntTarget = true
|
|
m.TauntTurns = 2
|
|
}
|
|
}
|
|
case entity.ClassMage:
|
|
intents = append(intents, combat.AttackIntent{
|
|
PlayerATK: p.EffectiveATK(),
|
|
TargetIdx: -1,
|
|
Multiplier: 0.8,
|
|
IsAoE: true,
|
|
})
|
|
case entity.ClassHealer:
|
|
if action.TargetIdx >= 0 && action.TargetIdx < len(s.state.Players) {
|
|
s.state.Players[action.TargetIdx].Heal(30)
|
|
}
|
|
case entity.ClassRogue:
|
|
// Scout: reveal neighboring rooms
|
|
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
|
|
for _, neighborIdx := range currentRoom.Neighbors {
|
|
s.state.Floor.Rooms[neighborIdx].Visited = true
|
|
}
|
|
}
|
|
case ActionItem:
|
|
// Use first consumable from inventory
|
|
for i, item := range p.Inventory {
|
|
if item.Type == entity.ItemConsumable {
|
|
p.Heal(item.Bonus)
|
|
p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
case ActionFlee:
|
|
// Individual flee does nothing if not unanimous (already handled above)
|
|
case ActionWait:
|
|
// Defensive stance — no action
|
|
}
|
|
}
|
|
|
|
if len(intents) > 0 && len(s.state.Monsters) > 0 {
|
|
combat.ResolveAttacks(intents, s.state.Monsters)
|
|
}
|
|
|
|
// 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
|
|
if goldReward > 15 {
|
|
goldReward = 15
|
|
}
|
|
for _, p := range s.state.Players {
|
|
if !p.IsDead() {
|
|
p.Gold += goldReward
|
|
}
|
|
}
|
|
// Boss kill: drop relic
|
|
if m.IsBoss {
|
|
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
|
|
// Check if this was the boss room -> advance floor
|
|
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 >= 20 {
|
|
s.state.Phase = PhaseResult
|
|
s.state.Victory = true
|
|
s.state.GameOver = true
|
|
return
|
|
}
|
|
s.state.FloorNum++
|
|
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
|
|
s.state.Phase = PhaseExploring
|
|
s.state.CombatTurn = 0
|
|
// Revive dead players at 30% HP
|
|
for _, p := range s.state.Players {
|
|
if p.IsDead() {
|
|
p.Revive(0.30)
|
|
}
|
|
}
|
|
}
|
|
|
|
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},
|
|
}
|
|
for _, p := range s.state.Players {
|
|
if !p.IsDead() {
|
|
r := relics[rand.Intn(len(relics))]
|
|
p.Relics = append(p.Relics, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// Boss AoE: 0.5x damage to all
|
|
for _, p := range s.state.Players {
|
|
if !p.IsDead() {
|
|
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5)
|
|
p.TakeDamage(dmg)
|
|
}
|
|
}
|
|
} else {
|
|
if targetIdx >= 0 && targetIdx < len(s.state.Players) {
|
|
p := s.state.Players[targetIdx]
|
|
if !p.IsDead() {
|
|
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
|
|
p.TakeDamage(dmg)
|
|
}
|
|
}
|
|
}
|
|
m.TickTaunt()
|
|
}
|
|
|
|
// Check party wipe
|
|
allPlayersDead := true
|
|
for _, p := range s.state.Players {
|
|
if !p.IsDead() {
|
|
allPlayersDead = false
|
|
break
|
|
}
|
|
}
|
|
if allPlayersDead {
|
|
s.state.Phase = PhaseResult
|
|
}
|
|
}
|