package client import ( "log" "path/filepath" "slices" "github.com/chompy/roguelike_rpg/internal/client/hud" nbattle "github.com/chompy/roguelike_rpg/lib/nbattle" "github.com/chompy/roguelike_rpg/lib/nbattle/event" "github.com/hajimehoshi/ebiten/v2" ) type CombatTurnState int const ( CombatStateStartTurn CombatTurnState = iota CombatStateMainMenu CombatStateActionMenu CombatStateTargetSelect CombatStateActionWait CombatStateEndTurn CombatStateEndCombat ) func (s CombatTurnState) String() string { switch s { case CombatStateStartTurn: return "start_turn" case CombatStateMainMenu: return "main_menu" case CombatStateActionMenu: return "action_menu" case CombatStateTargetSelect: return "target_select" case CombatStateActionWait: return "action_wait" case CombatStateEndTurn: return "end_turn" case CombatStateEndCombat: return "end_combat" default: return "unknown" } } type CombatState struct { Combatants []*Combatant ctx *GameContext nb *nbattle.Context menues map[CombatTurnState]hud.Element state CombatTurnState damageText []*hud.DamageText turnStartTime float32 } func (s *CombatState) Enter() error { // init nbattle context s.nb = nbattle.New() s.nb.NewStatDef("hp", 0, 99) s.nb.NewStatDef("max_hp", 0, 99) s.nb.NewStatDef("mp", 0, 99) s.nb.NewStatDef("max_mp", 0, 99) s.nb.NewStatDef("atk", 0, 99) s.nb.NewStatDef("def", 0, 99) s.nb.NewStatDef("mag", 0, 99) s.nb.NewStatDef("spd", 0, 99) s.nb.NewStatDef("water", -10, 10) s.nb.NewStatDef("fire", -10, 10) s.nb.NewStatDef("earth", -10, 10) s.nb.NewStatDef("lightning", -10, 10) // load effects effects := make([]string, 0) for _, action := range s.ctx.Actions { for effect := range action.Effects { if !slices.Contains(effects, effect) { effects = append(effects, effect) effectF, err := s.ctx.assetFS.Open(filepath.Join("combat", "effects", effect+".lua")) if err != nil { return err } defer effectF.Close() if _, err := s.nb.NewLuaEffect(effectF); err != nil { return err } } } } // init combatants for _, combatant := range s.Combatants { combatant.SetBattleCombatant(s.nb.NewCombatant()) combatant.Pos[0], combatant.Pos[1] = s.getCombatantPosition(combatant) combatant.TriggerSprite(TriggerStartCombat) for _, artifact := range combatant.Artifacts { action := s.ctx.Actions.Get(artifact) if action != nil { log.Printf("COMBAT: Activate artifact %s for %s.", artifact, combatant.String()) if err := s.UseAction(action, combatant, combatant); err != nil { return err } } } } s.nb.HookEvents(s.ProcessEvent) mainMenu, err := NewCombatMainMenu(s.ctx) if err != nil { return err } actionMenu, err := NewCombatActionMenu(s.ctx) if err != nil { return err } targetSelector, err := hud.NewTargetSelector(s.ctx.Gfx) if err != nil { return err } s.menues = map[CombatTurnState]hud.Element{ CombatStateMainMenu: mainMenu, CombatStateActionMenu: actionMenu, CombatStateTargetSelect: targetSelector, } s.NextTurn() return nil } func (s *CombatState) NextTurn() { s.nb.Tick() currentTurnCombatant := s.GetCurrentTurnCombatant() s.turnStartTime = s.ctx.Time() s.SetTurnState(CombatStateStartTurn) if s.IsPlayerVictory() || s.IsEnemyVictory() { s.SetTurnState(CombatStateEndCombat) return } if !currentTurnCombatant.Alive { s.NextTurn() return } // apply start turn visual effect triggers to this turn's combatant currentTurnCombatant.TriggerSprite(TriggerStartTurn) // setup action menu for player turn if currentTurnCombatant.IsPlayer() { actionMenu := s.menues[CombatStateActionMenu].(*hud.Menu) actionMenu.Reset() for _, action := range currentTurnCombatant.Role.Actions { actionMenu.Add("action." + action) } } } func (s *CombatState) getCombatantPosition(combatant *Combatant) (int, int) { teamCombatants := s.GetTeamCombatants(combatant.Team) teamCombatantsIndex := slices.Index(teamCombatants, combatant) x, y := GetCombatGridPosition(teamCombatantsIndex, len(teamCombatants)) if combatant.IsPlayer() { x += 64 } else { x += 400 } return x, y } func (s *CombatState) getActionTargetsCoordinates(action *Action) [][2]int { currentTurnCombatant := s.GetCurrentTurnCombatant() targets := action.Target.Filter(currentTurnCombatant, s.Combatants...) targetCoords := make([][2]int, len(targets)) for i, target := range targets { x, y := s.getCombatantPosition(target) targetCoords[i] = [2]int{x, y} } return targetCoords } func (s *CombatState) handleConfirm() error { if s.menues[s.state] == nil { return nil } switch s.state { case CombatStateMainMenu: menu := s.menues[s.state].(*hud.Menu) index := menu.Index() switch index { case 0: s.SetTurnState(CombatStateActionMenu) case 2: s.SetTurnState(CombatStateTargetSelect) } case CombatStateActionMenu: s.SetTurnState(CombatStateTargetSelect) case CombatStateTargetSelect: if err := s.UseSelectedAction(); err != nil { return err } } return nil } func (s *CombatState) handleCancel() error { switch s.state { case CombatStateActionMenu: s.SetTurnState(CombatStateMainMenu) case CombatStateTargetSelect: // if main menu selection wasn't "Act" then return to main menu from target select mainMenu := s.menues[CombatStateMainMenu].(*hud.Menu) if mainMenu.Index() != 0 { s.SetTurnState(CombatStateMainMenu) return nil } s.SetTurnState(CombatStateActionMenu) } return nil } func (s *CombatState) Update() (GameState, error) { currentTurnCombatant := s.GetCurrentTurnCombatant() switch s.state { case CombatStateStartTurn: log.Printf("COMBAT: Turn %d -- %s", s.nb.GetTick(), currentTurnCombatant.String()) // player combatant go to main menu s.SetTurnState(CombatStateMainMenu) // npc combatant automatically performs action and ends turn if !currentTurnCombatant.IsPlayer() { return nil, s.UseSelectedAction() } case CombatStateMainMenu, CombatStateActionMenu, CombatStateTargetSelect: if s.menues[s.state] != nil { SendCurrentInputToHudElement(s.menues[s.state]) if HasInput(InputConfirm) { return nil, s.handleConfirm() } if HasInput(InputCancel) { return nil, s.handleCancel() } } case CombatStateActionWait: if !s.HasActiveVisualEffect() { currentTurnCombatant.TriggerSprite(TriggerEndTurn) s.SetTurnState(CombatStateEndTurn) } case CombatStateEndTurn: if !s.HasActiveVisualEffect() { s.NextTurn() } } return nil, nil } func (s *CombatState) Draw(screen *ebiten.Image) { time := GetTime() for _, combatant := range s.Combatants { if combatant.Alive || combatant.HasActiveAnimation { combatant.Draw(time, screen) s.DrawCombatantBars(combatant, time, screen) } } for state, menu := range s.menues { if state != CombatStateActionMenu || s.state == CombatStateActionMenu { menu.Draw(time, screen) } } for _, damage := range s.damageText { damage.Draw(time, screen) } } func (s *CombatState) DrawCombatantBars(combatant *Combatant, time float32, screen *ebiten.Image) { if !combatant.Alive || combatant.Sprite == nil { return } visible := combatant.IsPlayer() if !visible && combatant.nb.HasEffect("reveal") { visible = true } if !visible { return } font, err := s.ctx.Gfx.FontLibrary.GetOne("menu") if err != nil { return } barWidth := combatant.HpBar.Config.Width barHeight := combatant.HpBar.Config.Height barX := combatant.Pos[0] + 4 - barWidth/2 hpY, mpY := s.getBarY(combatant, barHeight) currentHP := combatant.GetCurrentHP() maxHP := combatant.GetMaxHP() currentMP := combatant.GetCurrentMP() maxMP := combatant.GetMaxMP() hpTransition := -1 mpTransition := -1 if s.state == CombatStateTargetSelect { hpTransition, mpTransition = s.getPredictedValues(combatant) } combatant.HpBar.Current = currentHP combatant.HpBar.Max = maxHP combatant.HpBar.Transition = hpTransition combatant.MpBar.Current = currentMP combatant.MpBar.Max = maxMP combatant.MpBar.Transition = mpTransition combatant.HpBar.Draw(barX, hpY, time, screen, font) combatant.MpBar.Draw(barX, mpY, time, screen, font) } func (s *CombatState) getBarY(combatant *Combatant, barHeight int) (int, int) { _, th := combatant.Sprite.TileSize() spriteBottom := combatant.Pos[1] + 16 + th hpY := spriteBottom + 2 mpY := hpY + barHeight + 1 return hpY, mpY } func (s *CombatState) getPredictedValues(combatant *Combatant) (int, int) { action := s.GetSelectedAction() if action == nil { return -1, -1 } source := s.GetCurrentTurnCombatant() targets := action.Target.Filter(source, s.Combatants...) hpTransition := -1 mpTransition := -1 for _, t := range targets { if t == combatant { if _, hasAttack := action.Effects["attack"]; hasAttack { atk := 0 atkStat, err := source.nb.GetStat("atk") if err == nil { atk = atkStat.GetBase() } def := 0 defStat, err := combatant.nb.GetStat("def") if err == nil { def = defStat.GetBase() } dmg := atk - def if dmg < 0 { dmg = 0 } hpTransition = combatant.GetCurrentHP() - dmg } else if _, hasFire := action.Effects["fire"]; hasFire { mag := 0 magStat, err := source.nb.GetStat("mag") if err == nil { mag = magStat.GetBase() } fire := 0 fireStat, err := source.nb.GetStat("fire") if err == nil { fire = fireStat.GetBase() } wat := 0 watStat, err := combatant.nb.GetStat("water") if err == nil { wat = watStat.GetBase() } tFire := 0 tFireStat, err := combatant.nb.GetStat("fire") if err == nil { tFire = tFireStat.GetBase() } tDef := 0 tDefStat, err := combatant.nb.GetStat("def") if err == nil { tDef = tDefStat.GetBase() } def := wat + tFire/3 + tDef/5 dmg := mag + fire - def if dmg < 0 { dmg = 0 } hpTransition = combatant.GetCurrentHP() - dmg } else if _, hasHeal := action.Effects["heal"]; hasHeal { mag := 0 magStat, err := source.nb.GetStat("mag") if err == nil { mag = magStat.GetBase() } potency := action.Effects["heal"] heal := mag + potency hpTransition = combatant.GetCurrentHP() + heal if hpTransition > combatant.GetMaxHP() { hpTransition = combatant.GetMaxHP() } } } } if combatant == source { mpCost, hasCost := action.Effects["mp_cost"] if hasCost { mpTransition = combatant.GetCurrentMP() - mpCost if mpTransition < 0 { mpTransition = 0 } } } return hpTransition, mpTransition } func (s *CombatState) Exit() error { return nil } func (s *CombatState) HasActiveVisualEffect() bool { return slices.ContainsFunc(s.Combatants, func(c *Combatant) bool { return c.HasActiveAnimation }) } func (s *CombatState) GetTurnCombatant(turn int) *Combatant { return s.Combatants[(turn-1)%len(s.Combatants)] } func (s *CombatState) GetCurrentTurnCombatant() *Combatant { return s.GetTurnCombatant(s.nb.GetTick()) } func (s *CombatState) GetSelectedAction() *Action { mainMenu := s.menues[CombatStateMainMenu].(*hud.Menu) switch mainMenu.Index() { case 0: actionMenu := s.menues[CombatStateActionMenu].(*hud.Menu) actionName := s.GetCurrentTurnCombatant().Actions[actionMenu.Index()] return s.ctx.Actions.Get(actionName) case 2: return s.ctx.Actions.Get("defend") } return nil } func (s *CombatState) UseSelectedAction() error { combatant := s.GetCurrentTurnCombatant() if combatant.IsPlayer() { action := s.GetSelectedAction() if action == nil { return nil } targetSelector := s.menues[CombatStateTargetSelect].(*hud.TargetSelector) targetSelector.GetCurrentTargets() selectedTargets := make([]*Combatant, 0) for _, hudTarget := range targetSelector.GetCurrentTargets() { for _, combatant := range s.Combatants { if combatant.GetID() == hudTarget.ID { selectedTargets = append(selectedTargets, combatant) } } } if err := s.UseAction(action, combatant, selectedTargets...); err != nil { return err } } else { action := s.GetNPCTurnAction(combatant, s.nb.GetTick()) if err := s.UseAction(action, combatant, action.Target.Pick(combatant, s.Combatants...)...); err != nil { return err } } s.SetTurnState(CombatStateActionWait) return nil } func (s *CombatState) GetNPCTurnAction(combatant *Combatant, turn int) *Action { turnCountIndex := (turn - 1) / len(s.Combatants) actionName := combatant.Role.Actions[turnCountIndex%len(combatant.Role.Actions)] return s.ctx.Actions.Get(actionName) } func (s *CombatState) UseAction(action *Action, source *Combatant, targets ...*Combatant) error { log.Printf("COMBAT: %s uses %s.", source.String(), action.Name) for effect, potency := range action.Effects { for _, target := range targets { if err := target.nb.SetEffect(effect, source.nb, potency); err != nil { return err } } } return nil } func (s *CombatState) ProcessEvent(evt event.Event) error { switch evt := evt.(type) { case *event.CombatantStatBase: combatant := s.GetCombatantByID(evt.CombatantID) statDef, err := s.nb.GetStatDef(evt.StatDefID) if err != nil { return err } if statDef.GetName() == "hp" { hpStat, err := combatant.nb.GetStat("hp") if err != nil { return err } currentHP := hpStat.GetBase() diff := evt.Value - currentHP if diff < 0 { // damage if err := s.AddDamageText(combatant, -1*diff); err != nil { return err } if err := combatant.TriggerSprite(TriggerDamage); err != nil { return err } } else if diff > 0 { // heal if err := s.AddDamageText(combatant, diff); err != nil { return err } if err := combatant.TriggerSprite(TriggerHeal); err != nil { return err } } // death if evt.Value <= 0 { combatant.Alive = false if err := combatant.TriggerSprite(TriggerDeath); err != nil { return err } } } case *event.CombatantEffect: effectDef, err := s.nb.GetEffectDef(evt.EffectDefID) if err != nil { return err } target := s.GetCombatantByID(evt.TargetID) if evt.Potency > 0 { if err := target.TriggerEffectSprite(TriggerAddEffect, effectDef.GetName()); err != nil { return err } } else if evt.Potency == 0 { if err := target.TriggerEffectSprite(TriggerRemoveEffect, effectDef.GetName()); err != nil { return err } } } return nil } func (s *CombatState) AddDamageText(combatant *Combatant, damage int) error { damageText, err := hud.NewDamageText(s.ctx.Gfx, damage) if err != nil { return err } //cw, ch := combatant.Sprite.TileSize() damageTextSize := damageText.Image.Bounds().Size() damageText.SetPos(combatant.Pos[0]+(damageTextSize.X/2), combatant.Pos[1]) s.damageText = append(s.damageText, damageText) return nil } func (s *CombatState) SetTurnState(state CombatTurnState) { if s.menues[s.state] != nil { switch menu := s.menues[s.state].(type) { case *hud.Menu: menu.Active = false case *hud.TargetSelector: menu.Active = false } } log.Printf("COMBAT: Set turn state to %s", state) s.state = state if s.menues[s.state] != nil { switch menu := s.menues[s.state].(type) { case *hud.Menu: menu.Active = true case *hud.TargetSelector: menu.Active = true currentTurnCombatant := s.GetCurrentTurnCombatant() action := s.GetSelectedAction() targets := action.Target.Filter(currentTurnCombatant, s.Combatants...) menu.Reset() for _, target := range targets { menu.Add(target.GetID(), target.Pos[0], target.Pos[1], target.Team) } } } } func (s *CombatState) GetCombatantByID(ID int) *Combatant { for _, combatant := range s.Combatants { if combatant.GetID() == ID { return combatant } } return nil } func (s *CombatState) GetTeamCombatants(team int) []*Combatant { combatants := make([]*Combatant, 0) for _, combatant := range s.Combatants { if combatant.Team == team { combatants = append(combatants, combatant) } } return combatants } func (s *CombatState) IsPlayerVictory() bool { for _, combatant := range s.Combatants { if !combatant.IsPlayer() && combatant.Alive { return false } } return true } func (s *CombatState) IsEnemyVictory() bool { for _, combatant := range s.Combatants { if combatant.IsPlayer() && combatant.Alive { return false } } return true }