diff --git a/ui/menus/gameview/gameview.go b/ui/menus/gameview/gameview.go index 56691a2..cf96e54 100644 --- a/ui/menus/gameview/gameview.go +++ b/ui/menus/gameview/gameview.go @@ -36,6 +36,7 @@ type Model struct { inOpponentSelection bool inEnemyView bool animations []tea.Model + ctrlDown bool event tea.Model merchant tea.Model @@ -118,6 +119,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedCard = lo.Clamp(m.selectedCard-1, 0, len(m.Session.GetFight().Hand)-1) case tea.KeyRight: m.selectedCard = lo.Clamp(m.selectedCard+1, 0, len(m.Session.GetFight().Hand)-1) + case tea.KeyCtrlDown: + m.ctrlDown = true + case tea.KeyCtrlU: + m.ctrlDown = false + } + + // Show tooltip + if msg.String() == "x" { + for i := 0; i < m.Session.GetOpponentCount(game.PlayerActorID); i++ { + if m.zones.Get(fmt.Sprintf("%s%d", ZoneEnemy, i)).InBounds(m.LastMouse) { + cmds = append(cmds, root.TooltipCreate(root.ToolTip{ + ID: "ENEMY", + Content: m.fightEnemyInspectTooltipView(), + X: m.LastMouse.X, + Y: m.LastMouse.Y, + })) + } + } } // // Mouse @@ -126,6 +145,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.LastMouse = msg if msg.Type == tea.MouseLeft { + // Kill all enemy tooltips + cmds = append(cmds, root.TooltipDelete("ENEMY")) + switch m.Session.GetGameState() { case game.GameStateFight: if m.zones.Get(ZoneEndTurn).InBounds(msg) { @@ -142,8 +164,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.zones.Get(fmt.Sprintf("%s%d", ZoneEnemy, i)).InBounds(msg) { if msg.Type == tea.MouseLeft && m.selectedOpponent == i { m = m.tryCast() - } else { - m.selectedOpponent = i } } } @@ -408,6 +428,20 @@ func (m Model) fightCardViewHeight() int { return m.Size.Height - m.fightEnemyViewHeight() - 1 - 4 - 4 } +func (m Model) fightEnemyInspectTooltipView() string { + enemy := m.Session.GetOpponents(game.PlayerActorID)[m.selectedOpponent] + + intend := lipgloss.NewStyle().Bold(true).Underline(true).Foreground(style.BaseWhite).Render("Intend:") + "\n\n" + m.Session.GetActorIntend(enemy.GUID) + "\n\n" + + status := lipgloss.NewStyle().Bold(true).Underline(true).Foreground(style.BaseWhite).Render("Status Effects:") + "\n\n" + strings.Join(lo.Map(enemy.StatusEffects.ToSlice(), func(guid string, index int) string { + return components.StatusEffect(m.Session, guid) + ": " + m.Session.GetStatusEffectState(guid) + }), "\n\n") + + return lipgloss.NewStyle().Border(lipgloss.ThickBorder(), true).Padding(1, 2).BorderForeground(style.BaseRedDarker).Render( + lipgloss.NewStyle().Width(30).Render(intend + status), + ) +} + func (m Model) fightEnemyInspectView() string { enemy := m.Session.GetOpponents(game.PlayerActorID)[m.selectedOpponent] diff --git a/ui/overlay/overlay.go b/ui/overlay/overlay.go new file mode 100644 index 0000000..e321d57 --- /dev/null +++ b/ui/overlay/overlay.go @@ -0,0 +1,132 @@ +package overlay + +import ( + "bytes" + "strings" + + "github.com/mattn/go-runewidth" + "github.com/muesli/ansi" + "github.com/muesli/reflow/truncate" +) + +// Code borrowed and cut down from @mrusme and https://github.com/charmbracelet/lipgloss/pull/102 + +// Split a string into lines, additionally returning the size of the widest line. +func getLines(s string) (lines []string, widest int) { + lines = strings.Split(s, "\n") + + for _, l := range lines { + w := ansi.PrintableRuneWidth(l) + if widest < w { + widest = w + } + } + + return lines, widest +} + +// PlaceOverlay places overlay on top of background. +func PlaceOverlay(x, y int, overlay, background string) string { + overlayLines, overlayWidth := getLines(overlay) + backgroundLines, backgroundWidth := getLines(background) + backgroundHeight := len(backgroundLines) + overlayHeight := len(overlayLines) + + if overlayWidth >= backgroundWidth && overlayHeight >= backgroundHeight { + return overlay + } + + x = clamp(x, 0, backgroundWidth-overlayWidth) + y = clamp(y, 0, backgroundHeight-overlayHeight) + + var b strings.Builder + for i, backgroundLine := range backgroundLines { + if i > 0 { + b.WriteByte('\n') + } + if i < y || i >= y+overlayHeight { + b.WriteString(backgroundLine) + continue + } + + pos := 0 + if x > 0 { + left := truncate.String(backgroundLine, uint(x)) + pos = ansi.PrintableRuneWidth(left) + b.WriteString(left) + if pos < x { + pos = x + } + } + + overlayLine := overlayLines[i-y] + b.WriteString(overlayLine) + pos += ansi.PrintableRuneWidth(overlayLine) + + right := cutLeft(backgroundLine, pos) + b.WriteString(right) + } + + return b.String() +} + +// cutLeft cuts printable characters from the left. +// This function is heavily based on muesli's ansi and truncate packages. +func cutLeft(s string, cutWidth int) string { + var ( + pos int + isAnsi bool + ab bytes.Buffer + b bytes.Buffer + ) + + for _, c := range s { + var w int + if c == ansi.Marker || isAnsi { + isAnsi = true + ab.WriteRune(c) + if ansi.IsTerminator(c) { + isAnsi = false + if bytes.HasSuffix(ab.Bytes(), []byte("[0m")) { + ab.Reset() + } + } + } else { + w = runewidth.RuneWidth(c) + } + + if pos >= cutWidth { + if b.Len() == 0 { + if ab.Len() > 0 { + b.Write(ab.Bytes()) + } + if pos-cutWidth > 1 { + b.WriteByte(' ') + continue + } + } + b.WriteRune(c) + } + pos += w + } + + return b.String() +} + +func clamp(v, lower, upper int) int { + return min(max(v, lower), upper) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/ui/root/root.go b/ui/root/root.go index 136efa0..261e698 100644 --- a/ui/root/root.go +++ b/ui/root/root.go @@ -4,6 +4,7 @@ import ( "github.com/BigJk/end_of_eden/game" "github.com/BigJk/end_of_eden/ui" "github.com/BigJk/end_of_eden/ui/menus/lua_error" + "github.com/BigJk/end_of_eden/ui/overlay" tea "github.com/charmbracelet/bubbletea" zone "github.com/lrstanley/bubblezone" "github.com/samber/lo" @@ -17,21 +18,47 @@ func Push(model tea.Model) tea.Cmd { } } +type ToolTip struct { + ID string + Content string + X int + Y int +} + +type ToolTipMsg ToolTip + +func TooltipCreate(tip ToolTip) tea.Cmd { + return func() tea.Msg { + return ToolTipMsg(tip) + } +} + +type ToolTipDeleteMsg string + +func TooltipDelete(id string) tea.Cmd { + return func() tea.Msg { + return ToolTipDeleteMsg(id) + } +} + type Model struct { - zones *zone.Manager - stack []tea.Model - size tea.WindowSizeMsg + zones *zone.Manager + stack []tea.Model + size tea.WindowSizeMsg + tooltips map[string]ToolTip } func New(zones *zone.Manager, root tea.Model) Model { return Model{ - zones: zones, - stack: []tea.Model{root}, + zones: zones, + stack: []tea.Model{root}, + tooltips: map[string]ToolTip{}, } } func (m Model) PushModel(model tea.Model) Model { m.stack = append(m.stack, model) + m.tooltips = map[string]ToolTip{} return m } @@ -56,6 +83,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { return m, tea.Quit } + case ToolTipMsg: + m.tooltips[msg.ID] = ToolTip(msg) + case ToolTipDeleteMsg: + delete(m.tooltips, string(msg)) case PushModelMsg: m = m.PushModel(msg) } @@ -82,7 +113,14 @@ func (m Model) View() string { if len(m.stack) == 0 { return "stack empty!" } - return m.zones.Scan(m.stack[len(m.stack)-1].View()) + + view := m.zones.Scan(m.stack[len(m.stack)-1].View()) + + for _, v := range m.tooltips { + view = overlay.PlaceOverlay(v.X, v.Y, v.Content, view) + } + + return view } func CheckLuaErrors(zones *zone.Manager, s *game.Session) tea.Cmd {