go_games_collection/wayfarer_tui.go

1303 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"math/rand"
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// wayfarerView — текущий экран игры.
type wayfarerView int
const (
wfViewHub wayfarerView = iota
wfViewMarket
wfViewShipyard
wfViewTravel
// wfViewGalacticCoords — ввод координат (галактика + индекс
// системы) для межгалактического прыжка вместо случайной цели.
wfViewGalacticCoords
wfViewCombat
wfViewQuestList
wfViewQuestStep
wfViewQuestOffer
wfViewGameOver
)
// wayfarerRefuelPrice / wayfarerRepairPrice — цены за единицу
// топлива/прочности корпуса на верфи (фиксированные — свои для
// каждой станции цены усложнили бы UI без особой пользы).
const (
wayfarerRefuelPrice = 4.0
wayfarerRepairPrice = 6.0
wayfarerRegularMissilePrice = 150.0
wayfarerHomingMissilePrice = 300.0
wayfarerDecoyPrice = 80.0
)
// wayfarerEquipmentCost — стоимость разового приобретения снаряжения.
var wayfarerEquipmentCost = map[WayfarerEquipment]float64{
EquipFuelScoop: 800,
EquipCargoExpansion: 1200,
EquipShieldBooster: 1500,
EquipEscapePod: 2000,
EquipBetterLaser: 1800,
}
var wayfarerEquipmentNameKey = map[WayfarerEquipment]string{
EquipFuelScoop: "wf.equip.fuelscoop",
EquipCargoExpansion: "wf.equip.cargoexp",
EquipShieldBooster: "wf.equip.shieldbooster",
EquipEscapePod: "wf.equip.escapepod",
EquipBetterLaser: "wf.equip.betterlaser",
}
var wayfarerEquipmentOrder = []WayfarerEquipment{
EquipFuelScoop, EquipCargoExpansion, EquipShieldBooster, EquipEscapePod, EquipBetterLaser,
}
// wayfarerStartSystem — фиксированная стартовая система новой
// карьеры (детерминированно — как "Lave" в классической Elite).
var wayfarerStartSystem = WayfarerSystemID{Galaxy: 0, Index: 0}
// WayfarerModel — TUI поверх WayfarerShip.
type WayfarerModel struct {
ship *WayfarerShip
view wayfarerView
combat *WayfarerCombatState
combatRewardCredits float64 // награда за успешный бой/сигнал бедствия, если применимо
// combatDistributing / distFore/Aft/Sides — подрежим боя, в
// котором игрок временно настраивает распределение энергии щита
// по трём направлениям перед подтверждением (см.
// handleShieldDistributionKey). Раунд тратится только при
// подтверждении, не при входе в сам подрежим.
combatDistributing bool
distFocus int // 0=нос, 1=хвост, 2=левый борт, 3=правый борт — какое направление сейчас регулируется
distFore int
distAft int
distLeft int
distRight int
// combatChoosingFacing / pendingAttackWeapon — подрежим выбора
// направления удара по врагу после того, как игрок уже выбрал
// оружие (см. combatActions/handleFacingChoiceKey).
combatChoosingFacing bool
pendingAttackWeapon WayfarerWeaponType
cursor int // курсор для рынка/верфи/перелёта/квестов (что уместно в текущем view)
travelCandidates []WayfarerSystemID
// galacticTargetGalaxy / galacticTargetIndex — вводимые игроком
// координаты цели межгалактического прыжка (см. wfViewGalacticCoords).
galacticTargetGalaxy int
galacticTargetIndex int
pendingQuestKey string // ключ квеста, предложенного встречей в пути, но ещё не принятого/отклонённого
marketPrices []float64 // цены на товары в текущей системе — считаются один раз при стыковке, не на каждый рендер (см. rollMarketPrices)
confirmingReset bool // ждём подтверждения "начать новую карьеру" (y/n) — необратимое действие
message string
isError bool
rng *rand.Rand
termWidth int // для переноса длинного текста квестов/сообщений по ширине реального терминала
quitting bool
backToMenu bool
}
// NewWayfarerModel загружает сохранённую карьеру, если она есть, или
// начинает новую с фиксированной стартовой системы.
func NewWayfarerModel() WayfarerModel {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
var m WayfarerModel
if ship, err := LoadWayfarerShip(); err == nil {
m = WayfarerModel{ship: ship, view: wfViewHub, rng: rng, termWidth: 80}
} else {
m = WayfarerModel{ship: NewWayfarerShip(wayfarerStartSystem), view: wfViewHub, rng: rng, termWidth: 80}
}
m.rollMarketPrices()
return m
}
func (m WayfarerModel) Init() tea.Cmd { return nil }
func (m *WayfarerModel) setInfo(key string) {
m.message = T(key)
m.isError = false
}
func (m *WayfarerModel) setInfof(key string, args ...interface{}) {
m.message = Tf(key, args...)
m.isError = false
}
func (m *WayfarerModel) setError(err error) {
m.message = T(err.Error())
m.isError = true
}
func (m WayfarerModel) currentSystem() WayfarerSystem {
return GenerateSystem(m.ship.CurrentSystem)
}
// rollMarketPrices пересчитывает цены на товары в текущей системе.
// Вызывается один раз при стыковке (старт игры, загрузка карьеры,
// успешный прыжок) — а не при каждом рендере рынка, иначе цены
// мелькали бы на каждое нажатие клавиши, даже просто при движении
// курсора по списку товаров.
func (m *WayfarerModel) rollMarketPrices() {
sys := m.currentSystem()
m.marketPrices = make([]float64, len(WayfarerCommodities))
for i := range WayfarerCommodities {
m.marketPrices[i] = CommodityPrice(i, sys, m.rng)
}
}
func (m WayfarerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok {
m.termWidth = sizeMsg.Width
return m, nil
}
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
return m.handleKey(keyMsg)
}
func (m WayfarerModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
if key == "ctrl+c" {
_ = SaveWayfarerShip(m.ship)
m.quitting = true
return m, tea.Quit
}
if m.confirmingReset {
switch key {
case "y", "Y":
_ = DeleteWayfarerSave()
m.ship = NewWayfarerShip(wayfarerStartSystem)
m.confirmingReset = false
m.combat = nil
m.pendingQuestKey = ""
m.view = wfViewHub
m.rollMarketPrices()
m.setInfo("wf.msg.reset_done")
case "n", "N", "esc":
m.confirmingReset = false
m.message = ""
}
return m, nil
}
switch m.view {
case wfViewHub:
return m.handleHubKey(key)
case wfViewMarket:
return m.handleMarketKey(key)
case wfViewShipyard:
return m.handleShipyardKey(key)
case wfViewTravel:
return m.handleTravelKey(key)
case wfViewGalacticCoords:
return m.handleGalacticCoordsKey(key)
case wfViewCombat:
return m.handleCombatKey(key)
case wfViewQuestList:
return m.handleQuestListKey(key)
case wfViewQuestStep:
return m.handleQuestStepKey(key)
case wfViewQuestOffer:
return m.handleQuestOfferKey(key)
case wfViewGameOver:
return m.handleGameOverKey(key)
}
return m, nil
}
// --- Хаб станции -----------------------------------------------------
func (m WayfarerModel) handleHubKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "q":
_ = SaveWayfarerShip(m.ship)
m.backToMenu = true
return m, nil
case "1":
m.view = wfViewMarket
m.cursor = 0
m.message = ""
case "2":
m.view = wfViewShipyard
m.cursor = 0
m.message = ""
case "3":
m.travelCandidates = wayfarerNearbySystems(m.ship.CurrentSystem, m.ship.FuelRange())
m.view = wfViewTravel
m.cursor = 0
m.message = ""
case "4":
m.view = wfViewQuestList
m.cursor = 0
m.message = ""
case "N":
m.confirmingReset = true
}
return m, nil
}
// --- Рынок -------------------------------------------------------------
func (m WayfarerModel) handleMarketKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "q", "esc":
m.view = wfViewHub
m.message = ""
return m, nil
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(WayfarerCommodities)-1 {
m.cursor++
}
case "b", "s", "B", "S":
price := m.marketPrices[m.cursor]
switch key {
case "b":
if err := m.ship.BuyCommodity(m.cursor, 1, price); err != nil {
m.setError(err)
} else {
m.setInfof("wf.msg.bought", 1, T(WayfarerCommodities[m.cursor].Key))
}
case "B":
qty := m.ship.CargoFree()
affordable := int(m.ship.Credits / price)
if affordable < qty {
qty = affordable
}
if err := m.ship.BuyCommodity(m.cursor, qty, price); err != nil {
m.setError(err)
} else {
m.setInfof("wf.msg.bought", qty, T(WayfarerCommodities[m.cursor].Key))
}
case "s":
sellPrice := SellPrice(price)
if err := m.ship.SellCommodity(m.cursor, 1, sellPrice); err != nil {
m.setError(err)
} else {
m.setInfof("wf.msg.sold", 1, T(WayfarerCommodities[m.cursor].Key))
}
case "S":
sellPrice := SellPrice(price)
qty := m.ship.Cargo[m.cursor]
if err := m.ship.SellCommodity(m.cursor, qty, sellPrice); err != nil {
m.setError(err)
} else {
m.setInfof("wf.msg.sold", qty, T(WayfarerCommodities[m.cursor].Key))
}
}
}
return m, nil
}
// --- Верфь ---------------------------------------------------------------
func (m WayfarerModel) shipyardActionCount() int {
return 5 + len(wayfarerEquipmentOrder) // дозаправка + ремонт + 2 вида ракет + ловушки + снаряжение
}
func (m WayfarerModel) handleShipyardKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "q", "esc":
m.view = wfViewHub
m.message = ""
return m, nil
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < m.shipyardActionCount()-1 {
m.cursor++
}
case "enter", " ":
switch {
case m.cursor == 0:
need := m.ship.FuelCapacity - m.ship.Fuel
if err := m.ship.Refuel(need, wayfarerRefuelPrice); err != nil {
m.setError(err)
} else {
m.setInfo("wf.msg.refueled")
}
case m.cursor == 1:
need := m.ship.MaxHull - m.ship.Hull
if err := m.ship.Repair(need, wayfarerRepairPrice); err != nil {
m.setError(err)
} else {
m.setInfo("wf.msg.repaired")
}
case m.cursor == 2:
if err := m.ship.BuyMissile(WayfarerWeaponMissileRegular, wayfarerRegularMissilePrice); err != nil {
m.setError(err)
} else {
m.setInfo("wf.msg.missile_bought")
}
case m.cursor == 3:
if err := m.ship.BuyMissile(WayfarerWeaponMissileHoming, wayfarerHomingMissilePrice); err != nil {
m.setError(err)
} else {
m.setInfo("wf.msg.missile_bought")
}
case m.cursor == 4:
if err := m.ship.BuyDecoy(wayfarerDecoyPrice); err != nil {
m.setError(err)
} else {
m.setInfo("wf.msg.decoy_bought")
}
default:
eq := wayfarerEquipmentOrder[m.cursor-5]
if m.ship.Equipment[eq] {
m.setError(errWayfarerAlreadyOwned)
return m, nil
}
cost := wayfarerEquipmentCost[eq]
if m.ship.Credits < cost {
m.setError(errWayfarerInsufficientFunds)
return m, nil
}
m.ship.Credits -= cost
m.ship.Equipment[eq] = true
applyWayfarerEquipmentEffect(m.ship, eq)
m.setInfo("wf.msg.equipped")
}
}
return m, nil
}
// applyWayfarerEquipmentEffect применяет однократный эффект покупки
// снаряжения к статам корабля (там, где это применимо — щитовой
// бустер и улучшенный лазер сразу повышают соответствующие статы).
func applyWayfarerEquipmentEffect(s *WayfarerShip, eq WayfarerEquipment) {
switch eq {
case EquipShieldBooster:
s.MaxShield += 40
s.Shield += 40
case EquipBetterLaser:
s.LaserPower += 8
case EquipCargoExpansion:
s.CargoCapacity += 15
case EquipFuelScoop:
s.FuelCapacity += 3
s.Fuel += 3
}
}
var errWayfarerAlreadyOwned = wayfarerErr("это снаряжение уже установлено")
// --- Перелёт ---------------------------------------------------------------
// wayfarerNearbySystems находит до 9 ближайших систем в пределах
// maxRange световых лет от current (в той же галактике), плюс —
// отдельным пунктом в конце списка — возможность гиперпрыжка в
// следующую галактику (см. render/handle travel).
func wayfarerNearbySystems(current WayfarerSystemID, maxRange float64) []WayfarerSystemID {
from := GenerateSystem(current)
type candidate struct {
id WayfarerSystemID
dist float64
}
var candidates []candidate
for i := 0; i < WayfarerSystemsPerGalaxy; i++ {
if i == current.Index {
continue
}
id := WayfarerSystemID{Galaxy: current.Galaxy, Index: i}
dist := from.Distance(GenerateSystem(id))
if dist <= maxRange {
candidates = append(candidates, candidate{id, dist})
}
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].dist < candidates[j].dist })
if len(candidates) > 9 {
candidates = candidates[:9]
}
out := make([]WayfarerSystemID, len(candidates))
for i, c := range candidates {
out[i] = c.id
}
return out
}
func (m WayfarerModel) handleTravelKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "q", "esc":
m.view = wfViewHub
m.message = ""
return m, nil
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
max := len(m.travelCandidates) // +1 пункт межгалактического прыжка обрабатывается отдельным индексом
if m.cursor < max {
m.cursor++
}
case "enter", " ":
if m.cursor == len(m.travelCandidates) {
m.galacticTargetGalaxy = (m.ship.CurrentSystem.Galaxy + 1) % WayfarerGalaxyCount
m.galacticTargetIndex = 0
m.view = wfViewGalacticCoords
return m, nil
}
if m.cursor >= len(m.travelCandidates) {
return m, nil
}
target := m.travelCandidates[m.cursor]
return m.performJump(target)
}
return m, nil
}
// handleGalacticCoordsKey обрабатывает ввод координат (галактика +
// индекс системы) на экране выбора цели межгалактического прыжка.
func (m WayfarerModel) handleGalacticCoordsKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "q", "esc":
m.view = wfViewTravel
m.message = ""
return m, nil
case "left", "h":
m.galacticTargetGalaxy--
if m.galacticTargetGalaxy < 0 {
m.galacticTargetGalaxy = WayfarerGalaxyCount - 1
}
case "right", "l":
m.galacticTargetGalaxy = (m.galacticTargetGalaxy + 1) % WayfarerGalaxyCount
case "up", "k":
m.galacticTargetIndex++
if m.galacticTargetIndex >= WayfarerSystemsPerGalaxy {
m.galacticTargetIndex = 0
}
case "down", "j":
m.galacticTargetIndex--
if m.galacticTargetIndex < 0 {
m.galacticTargetIndex = WayfarerSystemsPerGalaxy - 1
}
case "shift+up":
m.galacticTargetIndex = (m.galacticTargetIndex + 10) % WayfarerSystemsPerGalaxy
case "shift+down":
m.galacticTargetIndex -= 10
if m.galacticTargetIndex < 0 {
m.galacticTargetIndex += WayfarerSystemsPerGalaxy
}
case "enter", " ":
return m.performGalacticJump(WayfarerSystemID{Galaxy: m.galacticTargetGalaxy, Index: m.galacticTargetIndex})
}
return m, nil
}
// performGalacticJump — межгалактический прыжок в явно выбранные
// игроком координаты (галактика + индекс системы). Требует почти
// полного бака — платит собой весь текущий запас топлива.
func (m WayfarerModel) performGalacticJump(target WayfarerSystemID) (tea.Model, tea.Cmd) {
if m.ship.Fuel < m.ship.FuelCapacity*0.9 {
m.setError(errWayfarerNeedFullTank)
return m, nil
}
if target.Galaxy == m.ship.CurrentSystem.Galaxy {
m.setError(errWayfarerSameGalaxy)
return m, nil
}
m.ship.Fuel = 0
m.ship.CurrentSystem = target
m.ship.Visited[target] = true
m.rollMarketPrices()
m.setInfo("wf.msg.galactic_jump")
m.view = wfViewHub
return m, nil
}
var errWayfarerNeedFullTank = wayfarerErr("для межгалактического прыжка нужен полный (или почти полный) бак")
var errWayfarerSameGalaxy = wayfarerErr("межгалактический прыжок должен вести в другую галактику")
// performJump выполняет обычный прыжок и разрешает случайную
// встречу в пути, если она произошла.
func (m WayfarerModel) performJump(target WayfarerSystemID) (tea.Model, tea.Cmd) {
if err := m.ship.JumpTo(target); err != nil {
m.setError(err)
return m, nil
}
m.rollMarketPrices()
dest := GenerateSystem(target)
encounter := RollEncounter(dest, m.rng)
switch encounter {
case EncounterNone:
if questKey, ok := RollQuestOffer(m.ship, m.rng); ok {
m.pendingQuestKey = questKey
m.view = wfViewQuestOffer
m.message = ""
return m, nil
}
m.setInfo("wf.msg.jump_quiet")
m.view = wfViewHub
case EncounterPirate:
hull, laser, shield := GeneratePirateStats(dest, m.rng)
m.combat = NewWayfarerCombat("wf.enemy.pirate", hull, laser,
m.ship.Hull, m.ship.MaxHull, m.ship.Shield, m.ship.MaxShield, m.ship.LaserPower, true, randomEnemyWeapon(m.rng), shield, m.rng)
m.combatRewardCredits = float64(hull) * 4
m.view = wfViewCombat
m.setInfo("wf.msg.pirate_ambush")
case EncounterPoliceScan:
if m.ship.HasIllegalCargo() {
lost := m.ship.ConfiscateIllegalCargo()
m.setInfof("wf.msg.cargo_confiscated", formatWfCredits(lost))
} else {
m.setInfo("wf.msg.scan_clear")
}
m.view = wfViewHub
case EncounterDistress:
reward, wasTrap := m.ship.HelpDistressed(m.rng)
if wasTrap {
hull, laser, shield := GeneratePirateStats(dest, m.rng)
m.combat = NewWayfarerCombat("wf.enemy.raider", hull, laser,
m.ship.Hull, m.ship.MaxHull, m.ship.Shield, m.ship.MaxShield, m.ship.LaserPower, true, randomEnemyWeapon(m.rng), shield, m.rng)
m.combatRewardCredits = float64(hull) * 4
m.view = wfViewCombat
m.setInfo("wf.msg.distress_trap")
} else {
m.setInfof("wf.msg.distress_reward", formatWfCredits(reward))
m.view = wfViewHub
}
case EncounterDerelict:
found := m.ship.DerelictFind(m.rng)
m.setInfof("wf.msg.derelict_find", formatWfCredits(found))
m.view = wfViewHub
}
return m, nil
}
// --- Бой -------------------------------------------------------------------
// wayfarerCombatAction — один пункт динамического списка боевых
// действий (какие именно доступны — зависит от боезапаса, наличия
// ловушек, типа оружия врага).
type wayfarerCombatAction struct {
Label string
Handle func(m *WayfarerModel)
}
// combatActions строит список доступных сейчас боевых действий.
// Порядок: атака лазером всегда первая, дальше — ракеты (если есть
// боезапас), бегство, переговоры (если применимо), уклонение (только
// против обычной ракеты врага), тепловая ловушка (только против
// самонаводящейся, и если ловушки есть), и распределение щитов —
// последним, всегда доступно. Выбор атаки (лазер/ракета) не бьёт
// сразу — сначала открывает подрежим выбора направления удара по
// врагу (см. handleFacingChoiceKey).
func (m WayfarerModel) combatActions() []wayfarerCombatAction {
c := m.combat
var actions []wayfarerCombatAction
actions = append(actions, wayfarerCombatAction{
Label: T("wf.combat.action.attack_laser"),
Handle: func(m *WayfarerModel) {
m.combatChoosingFacing = true
m.pendingAttackWeapon = WayfarerWeaponLaser
},
})
if m.ship.RegularMissiles > 0 {
actions = append(actions, wayfarerCombatAction{
Label: Tf("wf.combat.action.attack_missile_regular", m.ship.RegularMissiles),
Handle: func(m *WayfarerModel) {
m.combatChoosingFacing = true
m.pendingAttackWeapon = WayfarerWeaponMissileRegular
},
})
}
if m.ship.HomingMissiles > 0 {
actions = append(actions, wayfarerCombatAction{
Label: Tf("wf.combat.action.attack_missile_homing", m.ship.HomingMissiles),
Handle: func(m *WayfarerModel) {
m.combatChoosingFacing = true
m.pendingAttackWeapon = WayfarerWeaponMissileHoming
},
})
}
actions = append(actions, wayfarerCombatAction{
Label: T("wf.combat.action.flee"),
Handle: func(m *WayfarerModel) {
m.combat.Flee(m.rng)
},
})
if c.Negotiable {
actions = append(actions, wayfarerCombatAction{
Label: T("wf.combat.action.negotiate"),
Handle: func(m *WayfarerModel) {
m.combat.Negotiate(m.rng)
},
})
}
if c.EnemyWeapon == WayfarerWeaponMissileRegular {
actions = append(actions, wayfarerCombatAction{
Label: T("wf.combat.action.dodge"),
Handle: func(m *WayfarerModel) {
m.combat.Dodge(m.rng)
},
})
}
if c.EnemyWeapon == WayfarerWeaponMissileHoming && m.ship.Decoys > 0 {
actions = append(actions, wayfarerCombatAction{
Label: Tf("wf.combat.action.decoy", m.ship.Decoys),
Handle: func(m *WayfarerModel) {
m.combat.UseDecoy(m.ship)
},
})
}
actions = append(actions, wayfarerCombatAction{
Label: T("wf.combat.action.distribute"),
Handle: func(m *WayfarerModel) {
m.combatDistributing = true
m.distFocus = 0
m.distFore = m.combat.PlayerShieldFore
m.distAft = m.combat.PlayerShieldAft
m.distLeft = m.combat.PlayerShieldLeft
m.distRight = m.combat.PlayerShieldRight
},
})
return actions
}
func (m WayfarerModel) handleCombatKey(key string) (tea.Model, tea.Cmd) {
c := m.combat
if c == nil || c.Outcome != CombatOngoing {
return m, nil
}
if m.combatDistributing {
return m.handleShieldDistributionKey(key)
}
if m.combatChoosingFacing {
return m.handleFacingChoiceKey(key)
}
actions := m.combatActions()
for i, a := range actions {
if key == fmt.Sprintf("%d", i+1) {
a.Handle(&m)
if m.combatDistributing || m.combatChoosingFacing {
return m, nil // вошли в подрежим — раунд ещё не потрачен
}
return m.resolveCombatIfDone()
}
}
return m, nil
}
// wayfarerFacingKeys — какой клавишей выбирается каждое из четырёх
// направлений при прицельной атаке.
var wayfarerFacingKeys = map[string]WayfarerShieldFacing{
"1": WayfarerShieldFore,
"2": WayfarerShieldAft,
"3": WayfarerShieldLeft,
"4": WayfarerShieldRight,
}
// handleFacingChoiceKey обрабатывает выбор направления удара по
// врагу после того, как игрок уже выбрал оружие (лазер/ракета) —
// см. combatActions. Выбор направления сразу же выполняет атаку.
func (m WayfarerModel) handleFacingChoiceKey(key string) (tea.Model, tea.Cmd) {
if key == "esc" || key == "q" {
m.combatChoosingFacing = false
return m, nil
}
facing, ok := wayfarerFacingKeys[key]
if !ok {
return m, nil
}
m.combatChoosingFacing = false
switch m.pendingAttackWeapon {
case WayfarerWeaponLaser:
m.combat.Attack(facing, m.rng)
default:
if err := m.combat.AttackWithMissile(m.ship, m.pendingAttackWeapon, facing, m.rng); err != nil {
m.setError(err)
return m, nil
}
}
return m.resolveCombatIfDone()
}
// handleShieldDistributionKey обрабатывает ввод в подрежиме
// настройки распределения щита перед подтверждением.
func (m WayfarerModel) handleShieldDistributionKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "esc", "q":
m.combatDistributing = false
return m, nil
case "left", "h":
m.distFocus = (m.distFocus + 3) % 4
case "right", "l":
m.distFocus = (m.distFocus + 1) % 4
case "up", "k":
m.adjustDistFocus(5)
case "down", "j":
m.adjustDistFocus(-5)
case "enter", " ":
if err := m.combat.DistributeShields(m.distFore, m.distAft, m.distLeft, m.distRight, m.rng); err != nil {
m.setError(err)
return m, nil
}
m.combatDistributing = false
return m.resolveCombatIfDone()
}
return m, nil
}
func (m *WayfarerModel) adjustDistFocus(delta int) {
var target *int
switch m.distFocus {
case 0:
target = &m.distFore
case 1:
target = &m.distAft
case 2:
target = &m.distLeft
default:
target = &m.distRight
}
*target += delta
if *target < 0 {
*target = 0
}
if *target > m.combat.PlayerMaxShield {
*target = m.combat.PlayerMaxShield
}
}
func (m WayfarerModel) resolveCombatIfDone() (tea.Model, tea.Cmd) {
c := m.combat
switch c.Outcome {
case CombatWon:
m.ship.Hull = c.PlayerHull
m.ship.Shield = c.TotalShield()
m.ship.Kills++
m.ship.Credits += m.combatRewardCredits
m.setInfof("wf.msg.combat_won", formatWfCredits(m.combatRewardCredits))
m.view = wfViewHub
m.combat = nil
case CombatFled:
m.ship.Hull = c.PlayerHull
m.ship.Shield = c.TotalShield()
m.setInfo("wf.msg.combat_fled")
m.view = wfViewHub
m.combat = nil
case CombatNegotiated:
m.ship.Hull = c.PlayerHull
m.ship.Shield = c.TotalShield()
m.setInfo("wf.msg.combat_negotiated")
m.view = wfViewHub
m.combat = nil
case CombatLost:
m.ship.Hull = 0
if m.ship.Equipment[EquipEscapePod] {
m.ship.Equipment[EquipEscapePod] = false // одноразовое использование
m.ship.Hull = m.ship.MaxHull / 4
m.ship.Shield = 0
m.ship.Credits = m.ship.Credits / 2
for i := range m.ship.Cargo {
m.ship.Cargo[i] = 0
}
m.setInfo("wf.msg.escape_pod_used")
m.view = wfViewHub
m.combat = nil
} else {
m.view = wfViewGameOver
}
}
return m, nil
}
// --- Список квестов -----------------------------------------------------
// availableQuests возвращает квесты, которые ещё можно предложить
// игроку (не завершены и не провалены) — используется только для
// подсчёта в статусе и функцией RollQuestOffer, свободного выбора
// из этого списка больше нет (квесты теперь предлагаются случайно
// при прибытии в систему).
func (m WayfarerModel) availableQuests() []WayfarerQuestDef {
var out []WayfarerQuestDef
for _, q := range WayfarerQuestDefs {
if !m.ship.CompletedQuests[q.Key] && !m.ship.FailedQuests[q.Key] {
out = append(out, q)
}
}
return out
}
func (m WayfarerModel) handleQuestListKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "q", "esc":
m.view = wfViewHub
m.message = ""
case "enter", " ":
if m.ship.ActiveQuest != nil {
m.view = wfViewQuestStep
}
}
return m, nil
}
func (m WayfarerModel) handleQuestOfferKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "1", "enter", " ":
questKey := m.pendingQuestKey
m.pendingQuestKey = ""
if err := m.ship.StartQuest(questKey); err != nil {
m.setError(err)
m.view = wfViewHub
return m, nil
}
m.view = wfViewQuestStep
case "2", "q", "esc":
m.pendingQuestKey = ""
m.setInfo("wf.quests.declined")
m.view = wfViewHub
}
return m, nil
}
func (m WayfarerModel) viewQuestOffer() string {
var b strings.Builder
def, _ := FindWayfarerQuest(m.pendingQuestKey)
fmt.Fprintln(&b, titleStyle.Render(T("wf.quests.offer.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuCursorStyle.Render(T(def.TitleKey)))
fmt.Fprintln(&b, wfWrap(T(def.BriefKey), m.termWidth))
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.quests.offer.accept"))
fmt.Fprintln(&b, T("wf.quests.offer.decline"))
return b.String()
}
func (m WayfarerModel) handleQuestStepKey(key string) (tea.Model, tea.Cmd) {
step, ok := m.ship.CurrentQuestStep()
if !ok {
m.view = wfViewQuestList
m.cursor = 0
return m, nil
}
switch key {
case "q", "esc":
m.ship.AbandonQuest()
m.view = wfViewQuestList
m.cursor = 0
return m, nil
}
for i := 1; i <= 9 && i <= len(step.Choices); i++ {
if key == fmt.Sprintf("%d", i) {
choice, err := m.ship.ChooseQuestOption(i - 1)
if err != nil {
m.setError(err)
return m, nil
}
if choice.ResultKey != "" {
m.message = T(choice.ResultKey)
m.isError = false
}
if m.ship.ActiveQuest == nil {
m.view = wfViewQuestList
m.cursor = 0
}
return m, nil
}
}
return m, nil
}
// --- Экран окончания карьеры (гибель без спасательной капсулы) --------
func (m WayfarerModel) handleGameOverKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "n":
_ = DeleteWayfarerSave()
m.ship = NewWayfarerShip(wayfarerStartSystem)
m.view = wfViewHub
m.message = ""
m.combat = nil
case "q":
_ = DeleteWayfarerSave()
m.backToMenu = true
}
return m, nil
}
// --- Форматирование -----------------------------------------------------
func formatWfCredits(v float64) string {
return fmt.Sprintf("%.0f", v)
}
// --- Отрисовка ------------------------------------------------------------
func (m WayfarerModel) View() string {
if m.quitting {
return T("common.goodbye")
}
if m.confirmingReset {
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("wf.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, errorStyle.Render(T("wf.confirm_reset")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("wf.confirm_reset.hint")))
return b.String()
}
switch m.view {
case wfViewMarket:
return m.viewMarket()
case wfViewShipyard:
return m.viewShipyard()
case wfViewTravel:
return m.viewTravel()
case wfViewGalacticCoords:
return m.viewGalacticCoords()
case wfViewCombat:
return m.viewCombat()
case wfViewQuestList:
return m.viewQuestList()
case wfViewQuestStep:
return m.viewQuestStep()
case wfViewQuestOffer:
return m.viewQuestOffer()
case wfViewGameOver:
return m.viewGameOver()
default:
return m.viewHub()
}
}
func (m WayfarerModel) renderShipStatus() string {
s := m.ship
return Tf("wf.status_line",
formatWfCredits(s.Credits), s.CargoUsed(), s.CargoCapacity,
s.Fuel, s.FuelCapacity, s.Hull, s.MaxHull, s.Shield, s.MaxShield)
}
// wfWrap — тонкая обёртка над общей wrapText (сохранена ради
// минимальных изменений во всех местах вызова в этом файле).
func wfWrap(text string, termWidth int) string {
return wrapText(text, termWidth)
}
func (m WayfarerModel) renderMessage(b *strings.Builder) {
if m.message == "" {
return
}
fmt.Fprintln(b)
if m.isError {
fmt.Fprintln(b, errorStyle.Render(wfWrap("! "+m.message, m.termWidth)))
} else {
fmt.Fprintln(b, infoStyle.Render(wfWrap(m.message, m.termWidth)))
}
}
func (m WayfarerModel) viewHub() string {
sys := m.currentSystem()
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("wf.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, Tf("wf.system_line", sys.Name, T(wayfarerGovernmentNameKeys[sys.Government]), sys.TechLevel))
fmt.Fprintln(&b, m.renderShipStatus())
fmt.Fprintln(&b, Tf("wf.rating_line", T(m.ship.PilotRating()), m.ship.Kills))
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.hub.hint1"))
fmt.Fprintln(&b, T("wf.hub.hint2"))
m.renderMessage(&b)
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("wf.hint.quit")))
return b.String()
}
func (m WayfarerModel) viewMarket() string {
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("wf.market.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, m.renderShipStatus())
fmt.Fprintln(&b)
for i, c := range WayfarerCommodities {
price := m.marketPrices[i]
line := Tf("wf.market.line", T(c.Key), price, m.ship.Cargo[i])
if m.ship.Cargo[i] > 0 {
line += Tf("wf.market.bought_at", m.ship.CargoCostBasis[i])
}
if c.Illegal {
line += " " + dimStyle.Render(T("wf.market.illegal_tag"))
}
if i == m.cursor {
line = menuCursorStyle.Render("> " + line)
} else {
line = " " + line
}
fmt.Fprintln(&b, line)
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.market.hint"))
m.renderMessage(&b)
return b.String()
}
func (m WayfarerModel) viewShipyard() string {
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("wf.shipyard.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, m.renderShipStatus())
fmt.Fprintln(&b)
lines := []string{
Tf("wf.shipyard.refuel", (m.ship.FuelCapacity-m.ship.Fuel)*wayfarerRefuelPrice),
Tf("wf.shipyard.repair", float64(m.ship.MaxHull-m.ship.Hull)*wayfarerRepairPrice),
Tf("wf.shipyard.missile_regular", wayfarerRegularMissilePrice, m.ship.RegularMissiles, m.ship.MissilesLoaded(), m.ship.MissileCapacity),
Tf("wf.shipyard.missile_homing", wayfarerHomingMissilePrice, m.ship.HomingMissiles, m.ship.MissilesLoaded(), m.ship.MissileCapacity),
Tf("wf.shipyard.decoy", wayfarerDecoyPrice, m.ship.Decoys),
}
for _, eq := range wayfarerEquipmentOrder {
status := T("wf.shipyard.available")
if m.ship.Equipment[eq] {
status = T("wf.shipyard.owned")
}
lines = append(lines, Tf("wf.shipyard.equip_line", T(wayfarerEquipmentNameKey[eq]), wayfarerEquipmentCost[eq], status))
}
for i, line := range lines {
if i == m.cursor {
fmt.Fprintln(&b, menuCursorStyle.Render("> "+line))
} else {
fmt.Fprintln(&b, " "+line)
}
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.shipyard.hint"))
m.renderMessage(&b)
return b.String()
}
func (m WayfarerModel) viewGalacticCoords() string {
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("wf.galactic.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, Tf("wf.galactic.current", m.ship.CurrentSystem.Galaxy, m.ship.CurrentSystem.Index))
fmt.Fprintln(&b)
target := WayfarerSystemID{Galaxy: m.galacticTargetGalaxy, Index: m.galacticTargetIndex}
fmt.Fprintln(&b, menuCursorStyle.Render(Tf("wf.galactic.target", target.Galaxy, target.Index)))
fmt.Fprintln(&b)
preview := GenerateSystem(target)
fmt.Fprintln(&b, Tf("wf.galactic.preview", preview.Name, T(wayfarerGovernmentNameKeys[preview.Government]), preview.TechLevel))
if m.ship.Visited[target] {
fmt.Fprintln(&b, dimStyle.Render(T("wf.galactic.visited")))
} else {
fmt.Fprintln(&b, dimStyle.Render(T("wf.galactic.unvisited")))
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.galactic.hint"))
m.renderMessage(&b)
return b.String()
}
func (m WayfarerModel) viewTravel() string {
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("wf.travel.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, Tf("wf.travel.range", m.ship.FuelRange()))
fmt.Fprintln(&b)
if len(m.travelCandidates) == 0 {
fmt.Fprintln(&b, dimStyle.Render(T("wf.travel.none_in_range")))
}
for i, id := range m.travelCandidates {
sys := GenerateSystem(id)
dist := m.currentSystem().Distance(sys)
line := Tf("wf.travel.line", sys.Name, T(wayfarerGovernmentNameKeys[sys.Government]), dist)
if i == m.cursor {
line = menuCursorStyle.Render("> " + line)
} else {
line = " " + line
}
fmt.Fprintln(&b, line)
}
galacticLine := T("wf.travel.galactic_jump")
if m.cursor == len(m.travelCandidates) {
galacticLine = menuCursorStyle.Render("> " + galacticLine)
} else {
galacticLine = " " + galacticLine
}
fmt.Fprintln(&b, galacticLine)
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.travel.hint"))
m.renderMessage(&b)
return b.String()
}
func (m WayfarerModel) viewCombat() string {
c := m.combat
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("wf.combat.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, Tf("wf.combat.enemy_line", T(c.EnemyNameKey), c.EnemyHull, c.EnemyMaxHull))
fmt.Fprintln(&b, dimStyle.Render(Tf("wf.combat.enemy_weapon", T(wayfarerWeaponNameKey[c.EnemyWeapon]))))
fmt.Fprintln(&b, Tf("wf.combat.enemy_shields_line", c.EnemyShieldFore, c.EnemyShieldAft, c.EnemyShieldLeft, c.EnemyShieldRight, c.EnemyMaxShield))
fmt.Fprintln(&b)
fmt.Fprintln(&b, Tf("wf.combat.player_hull", c.PlayerHull, c.PlayerMaxHull))
fmt.Fprintln(&b, Tf("wf.combat.shields_line", c.PlayerShieldFore, c.PlayerShieldAft, c.PlayerShieldLeft, c.PlayerShieldRight, c.PlayerMaxShield))
fmt.Fprintln(&b)
if c.Round > 0 {
fmt.Fprintln(&b, Tf("wf.combat.round_result", c.LastPlayerDamage, c.LastEnemyDamage, T(wayfarerFacingNameKey[c.LastHitFacing])))
}
if c.Outcome == CombatOngoing {
fmt.Fprintln(&b, errorStyle.Render(Tf("wf.combat.approach_hint", T(wayfarerApproachNameKey[c.NextApproach]))))
}
if m.combatChoosingFacing && c.Outcome == CombatOngoing {
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.combat.facing_choice.title"))
fmt.Fprintf(&b, "[1] %s\n", T("wf.combat.facing.fore"))
fmt.Fprintf(&b, "[2] %s\n", T("wf.combat.facing.aft"))
fmt.Fprintf(&b, "[3] %s\n", T("wf.combat.facing.left"))
fmt.Fprintf(&b, "[4] %s\n", T("wf.combat.facing.right"))
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("wf.combat.facing_choice.hint")))
m.renderMessage(&b)
return b.String()
}
if m.combatDistributing && c.Outcome == CombatOngoing {
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.combat.distribute.title"))
focusLine := func(idx int, label string, val int) string {
line := Tf("wf.combat.distribute.line", label, val)
if idx == m.distFocus {
return menuCursorStyle.Render("> " + line)
}
return " " + line
}
fmt.Fprintln(&b, focusLine(0, T("wf.combat.facing.fore"), m.distFore))
fmt.Fprintln(&b, focusLine(1, T("wf.combat.facing.aft"), m.distAft))
fmt.Fprintln(&b, focusLine(2, T("wf.combat.facing.left"), m.distLeft))
fmt.Fprintln(&b, focusLine(3, T("wf.combat.facing.right"), m.distRight))
total := m.distFore + m.distAft + m.distLeft + m.distRight
fmt.Fprintln(&b, Tf("wf.combat.distribute.total", total, c.PlayerMaxShield))
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.combat.distribute.hint"))
m.renderMessage(&b)
return b.String()
}
switch c.Outcome {
case CombatOngoing:
fmt.Fprintln(&b)
for i, a := range m.combatActions() {
fmt.Fprintf(&b, "[%d] %s\n", i+1, a.Label)
}
case CombatWon:
fmt.Fprintln(&b, winStyle.Render(T("wf.combat.outcome.won")))
case CombatFled:
fmt.Fprintln(&b, infoStyle.Render(T("wf.combat.outcome.fled")))
case CombatNegotiated:
fmt.Fprintln(&b, infoStyle.Render(T("wf.combat.outcome.negotiated")))
case CombatLost:
fmt.Fprintln(&b, errorStyle.Render(T("wf.combat.outcome.lost")))
}
m.renderMessage(&b)
return b.String()
}
func (m WayfarerModel) viewQuestList() string {
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("wf.quests.title")))
fmt.Fprintln(&b)
if m.ship.ActiveQuest != nil {
def, _ := FindWayfarerQuest(m.ship.ActiveQuest.Key)
fmt.Fprintln(&b, Tf("wf.quests.active", T(def.TitleKey)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.quests.hint.continue"))
m.renderMessage(&b)
return b.String()
}
remaining := len(m.availableQuests())
done := len(WayfarerQuestDefs) - remaining
fmt.Fprintln(&b, dimStyle.Render(Tf("wf.quests.progress", done, len(WayfarerQuestDefs))))
fmt.Fprintln(&b)
if remaining == 0 {
fmt.Fprintln(&b, T("wf.quests.all_done"))
} else {
fmt.Fprintln(&b, T("wf.quests.none_active"))
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.quests.hint.list"))
m.renderMessage(&b)
return b.String()
}
func (m WayfarerModel) viewQuestStep() string {
var b strings.Builder
def, _ := FindWayfarerQuest(m.ship.ActiveQuest.Key)
step, ok := m.ship.CurrentQuestStep()
fmt.Fprintln(&b, titleStyle.Render(T(def.TitleKey)))
fmt.Fprintln(&b)
if !ok {
m.renderMessage(&b)
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.quests.hint.back"))
return b.String()
}
fmt.Fprintln(&b, wfWrap(T(step.TextKey), m.termWidth))
fmt.Fprintln(&b)
for i, c := range step.Choices {
label := fmt.Sprintf("[%d] %s", i+1, T(c.LabelKey))
fmt.Fprintln(&b, wfWrap(label, m.termWidth))
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("wf.quests.hint.abandon"))
m.renderMessage(&b)
return b.String()
}
func (m WayfarerModel) viewGameOver() string {
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("wf.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, errorStyle.Render(T("wf.gameover.destroyed")))
fmt.Fprintln(&b, Tf("wf.gameover.stats", T(m.ship.PilotRating()), m.ship.Kills))
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("wf.gameover.hint")))
return b.String()
}
// wayfarerGovernmentNameKeys — ключи перевода названий правительств
// (индекс = значение WayfarerGovernment).
var wayfarerGovernmentNameKeys = [...]string{
"wf.gov.anarchy", "wf.gov.feudal", "wf.gov.dictatorship", "wf.gov.communist",
"wf.gov.confederacy", "wf.gov.democracy", "wf.gov.corporate",
}