927 lines
27 KiB
Go
927 lines
27 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
// wayfarerView — текущий экран игры.
|
||
type wayfarerView int
|
||
|
||
const (
|
||
wfViewHub wayfarerView = iota
|
||
wfViewMarket
|
||
wfViewShipyard
|
||
wfViewTravel
|
||
wfViewCombat
|
||
wfViewQuestList
|
||
wfViewQuestStep
|
||
wfViewQuestOffer
|
||
wfViewGameOver
|
||
)
|
||
|
||
// wayfarerRefuelPrice / wayfarerRepairPrice — цены за единицу
|
||
// топлива/прочности корпуса на верфи (фиксированные — свои для
|
||
// каждой станции цены усложнили бы UI без особой пользы).
|
||
const (
|
||
wayfarerRefuelPrice = 4.0
|
||
wayfarerRepairPrice = 6.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 // награда за успешный бой/сигнал бедствия, если применимо
|
||
|
||
cursor int // курсор для рынка/верфи/перелёта/квестов (что уместно в текущем view)
|
||
|
||
travelCandidates []WayfarerSystemID
|
||
|
||
pendingQuestKey string // ключ квеста, предложенного встречей в пути, но ещё не принятого/отклонённого
|
||
|
||
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()))
|
||
if ship, err := LoadWayfarerShip(); err == nil {
|
||
return WayfarerModel{ship: ship, view: wfViewHub, rng: rng, termWidth: 80}
|
||
}
|
||
return WayfarerModel{ship: NewWayfarerShip(wayfarerStartSystem), view: wfViewHub, rng: rng, termWidth: 80}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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 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 = ""
|
||
}
|
||
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":
|
||
sys := m.currentSystem()
|
||
price := CommodityPrice(m.cursor, sys, m.rng)
|
||
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 2 + len(wayfarerEquipmentOrder) // дозаправка + ремонт + снаряжение
|
||
}
|
||
|
||
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")
|
||
}
|
||
default:
|
||
eq := wayfarerEquipmentOrder[m.cursor-2]
|
||
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) {
|
||
return m.performGalacticJump()
|
||
}
|
||
if m.cursor >= len(m.travelCandidates) {
|
||
return m, nil
|
||
}
|
||
target := m.travelCandidates[m.cursor]
|
||
return m.performJump(target)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// performGalacticJump — межгалактический прыжок в случайную систему
|
||
// следующей галактики (по кругу после последней). Требует почти
|
||
// полного бака — платит собой весь текущий запас топлива.
|
||
func (m WayfarerModel) performGalacticJump() (tea.Model, tea.Cmd) {
|
||
if m.ship.Fuel < m.ship.FuelCapacity*0.9 {
|
||
m.setError(errWayfarerNeedFullTank)
|
||
return m, nil
|
||
}
|
||
nextGalaxy := (m.ship.CurrentSystem.Galaxy + 1) % WayfarerGalaxyCount
|
||
target := WayfarerSystemID{Galaxy: nextGalaxy, Index: m.rng.Intn(WayfarerSystemsPerGalaxy)}
|
||
m.ship.Fuel = 0
|
||
m.ship.CurrentSystem = target
|
||
m.ship.Visited[target] = true
|
||
m.setInfo("wf.msg.galactic_jump")
|
||
m.view = wfViewHub
|
||
return m, nil
|
||
}
|
||
|
||
var errWayfarerNeedFullTank = 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
|
||
}
|
||
|
||
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 := 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)
|
||
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 := 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)
|
||
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
|
||
}
|
||
|
||
// --- Бой -------------------------------------------------------------------
|
||
|
||
func (m WayfarerModel) handleCombatKey(key string) (tea.Model, tea.Cmd) {
|
||
c := m.combat
|
||
if c == nil || c.Outcome != CombatOngoing {
|
||
return m, nil
|
||
}
|
||
switch key {
|
||
case "1":
|
||
c.Attack(m.rng)
|
||
case "2":
|
||
c.Flee(m.rng)
|
||
case "3":
|
||
if c.Negotiable {
|
||
c.Negotiate(m.rng)
|
||
}
|
||
default:
|
||
return m, nil
|
||
}
|
||
return m.resolveCombatIfDone()
|
||
}
|
||
|
||
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.PlayerShield
|
||
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.PlayerShield
|
||
m.setInfo("wf.msg.combat_fled")
|
||
m.view = wfViewHub
|
||
m.combat = nil
|
||
case CombatNegotiated:
|
||
m.ship.Hull = c.PlayerHull
|
||
m.ship.Shield = c.PlayerShield
|
||
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")
|
||
}
|
||
switch m.view {
|
||
case wfViewMarket:
|
||
return m.viewMarket()
|
||
case wfViewShipyard:
|
||
return m.viewShipyard()
|
||
case wfViewTravel:
|
||
return m.viewTravel()
|
||
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 переносит длинный текст (обычно — прозу квестов) по
|
||
// ширине реального терминала, чтобы строка не обрезалась и не
|
||
// вылезала за пределы экрана без переноса. Ширина ограничена
|
||
// разумным диапазоном: не уже 40 колонок (совсем узкие терминалы) и
|
||
// не шире 100 (сплошной текст на весь широкий терминал читать
|
||
// неудобно).
|
||
func wfWrap(text string, termWidth int) string {
|
||
width := termWidth - 4
|
||
if width < 40 {
|
||
width = 60 // разумный запасной вариант, пока реальный размер ещё не пришёл через WindowSizeMsg
|
||
}
|
||
if width > 100 {
|
||
width = 100
|
||
}
|
||
return lipgloss.NewStyle().Width(width).Render(text)
|
||
}
|
||
|
||
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 {
|
||
sys := m.currentSystem()
|
||
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 := CommodityPrice(i, sys, m.rng)
|
||
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),
|
||
}
|
||
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) 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, Tf("wf.combat.player_line", c.PlayerHull, c.PlayerMaxHull, c.PlayerShield, c.PlayerMaxShield))
|
||
fmt.Fprintln(&b)
|
||
if c.Round > 0 {
|
||
fmt.Fprintln(&b, Tf("wf.combat.round_result", c.LastPlayerDamage, c.LastEnemyDamage))
|
||
}
|
||
|
||
switch c.Outcome {
|
||
case CombatOngoing:
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, T("wf.combat.hint.attack"))
|
||
fmt.Fprintln(&b, T("wf.combat.hint.flee"))
|
||
if c.Negotiable {
|
||
fmt.Fprintln(&b, T("wf.combat.hint.negotiate"))
|
||
}
|
||
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",
|
||
}
|