phase 0.3

This commit is contained in:
portakal 2026-06-04 20:51:20 +03:00
commit 4a94bfc4e7
52 changed files with 11653 additions and 0 deletions

203
internal/ui/shop.go Normal file
View file

@ -0,0 +1,203 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"skyrama-tui/internal/model"
"skyrama-tui/internal/service"
"skyrama-tui/internal/style"
)
// ─── Messages ─────────────────────────────────────────────────────────────────
type ShopLoadedMsg struct{ Planes []model.Plane }
type ShopErrMsg struct{ Err error }
type PlaneBoughtMsg struct{ Name string }
// ─── List item ────────────────────────────────────────────────────────────────
type shopPlaneItem struct{ p model.Plane }
func (s shopPlaneItem) Title() string {
return fmt.Sprintf("%s %s %s",
planeIcon(s.p.AircraftType),
s.p.Name,
style.CashChip.Render(fmt.Sprintf("$%d", s.p.BuyingCostCash)),
)
}
func (s shopPlaneItem) Description() string {
parts := []string{
style.Muted.Render(fmt.Sprintf("lv.%d", s.p.MinLevel)),
style.Muted.Render(s.p.AircraftSize + "/" + s.p.AircraftType),
style.FuelChip.Render(fmt.Sprintf("⛽%d", s.p.OperationCostFuel)),
style.Muted.Render(fmt.Sprintf("⏱%s", fmtDuration(int(s.p.TravelTimeInSeconds)))),
style.Muted.Render(fmt.Sprintf("✨%d xp", s.p.CompletedFlightExperience)),
}
if s.p.MaxPassengers.Valid && s.p.MaxPassengers.Int32 > 0 {
parts = append(parts,
style.PassengerChip.Render(fmt.Sprintf("👤%d", s.p.MaxPassengers.Int32)))
}
if s.p.CompletedFlightCashReward.Valid {
parts = append(parts,
style.CashChip.Render(fmt.Sprintf("💰%d/flight", s.p.CompletedFlightCashReward.Int32)))
}
return strings.Join(parts, " ")
}
func (s shopPlaneItem) FilterValue() string { return s.p.Name }
// ─── ShopModel ────────────────────────────────────────────────────────────────
type ShopModel struct {
client *service.GameService
airportID uint32
playerLevel uint32
list list.Model
planes []model.Plane
statusMsg string
focused bool
width int
height int
}
func NewShopModel(client *service.GameService, airportID uint32, playerLevel uint32) ShopModel {
d := list.NewDefaultDelegate()
d.Styles.SelectedTitle = d.Styles.SelectedTitle.
Foreground(style.ColorGold).
BorderForeground(style.ColorGold)
d.Styles.SelectedDesc = d.Styles.SelectedDesc.
Foreground(style.ColorPassenger).
BorderForeground(style.ColorGold)
l := list.New(nil, d, 0, 0)
l.Title = ""
l.SetShowTitle(false)
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true)
return ShopModel{
client: client,
airportID: airportID,
playerLevel: playerLevel,
list: l,
}
}
func (m ShopModel) Init() tea.Cmd { return m.load() }
func (m ShopModel) Update(msg tea.Msg) (ShopModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.SetSize(m.width-4, m.height-12)
case ShopLoadedMsg:
m.planes = msg.Planes
m.list.SetItems(m.planesToItems())
case PlaneBoughtMsg:
m.statusMsg = style.Success.String() + "Bought: " + msg.Name + "!"
return m, m.load()
case ShopErrMsg:
m.statusMsg = style.Error.String() + msg.Err.Error()
case tea.KeyMsg:
if !m.focused {
break
}
switch msg.String() {
case "r":
m.statusMsg = ""
return m, m.load()
case "enter", " ", "b":
item, ok := m.list.SelectedItem().(shopPlaneItem)
if !ok {
break
}
if item.p.MinLevel > m.playerLevel {
m.statusMsg = style.Warning.String() +
fmt.Sprintf("Requires level %d (you are level %d)",
item.p.MinLevel, m.playerLevel)
break
}
return m, m.buy(item.p)
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m ShopModel) View() string {
header := style.PanelHeader.Render("🛒 Plane Shop")
levelNote := style.Info.String() + style.Muted.Render(
fmt.Sprintf("Your level: %d", m.playerLevel),
)
statusLine := ""
if m.statusMsg != "" {
statusLine = "\n" + m.statusMsg + "\n"
}
help := style.HelpKey.Render("enter / b") + style.HelpDesc.Render(" buy ") +
style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh ") +
style.HelpKey.Render("/") + style.HelpDesc.Render(" filter")
return lipgloss.JoinVertical(lipgloss.Left,
header,
levelNote,
statusLine,
m.list.View(),
"",
style.Muted.Render(help),
)
}
// ─── Exported for parent AppModel ─────────────────────────────────────────────
// Load is the exported version for use by parent AppModel.
func (m ShopModel) Load() tea.Cmd { return m.load() }
func (m ShopModel) load() tea.Cmd {
client := m.client
return func() tea.Msg {
planes, err := client.GetAllPlanes()
if err != nil {
return ShopErrMsg{Err: err}
}
return ShopLoadedMsg{Planes: planes}
}
}
func (m ShopModel) buy(p model.Plane) tea.Cmd {
client := m.client
airportID := m.airportID
planeID := p.ID
planeName := p.Name
return func() tea.Msg {
// The service finds a free hangar and parks the plane in one intent.
if err := client.BuyPlane(airportID, planeID); err != nil {
return ShopErrMsg{Err: err}
}
return PlaneBoughtMsg{Name: planeName}
}
}
func (m ShopModel) planesToItems() []list.Item {
items := make([]list.Item, len(m.planes))
for i, p := range m.planes {
items[i] = shopPlaneItem{p: p}
}
return items
}