aboutsummaryrefslogtreecommitdiff
path: root/internal/game/render_plinko.go
blob: 7f566b3ccf3adb8f9b4363ab973d7659ee92b7a1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
package game

import (
	"fmt"
	"math"
	"strings"

	"thehouseoficarus/internal/casino"
	"thehouseoficarus/internal/color"
)

const plinkoWidth = 69

// plinkoRows is the board height (16 bounces, 17 pockets).
const plinkoRows = 16

func plinkoBallColor(count, total int) int {
	shade := 255
	if total > 1 && count > 1 {
		shade = 255 * (total - count) / (total - 1)
	}
	return color.NearestXterm256(255, shade, shade)
}

// plinkoGradientRGB is the board's shared hue: icy white at the center,
// deepening through sky blue and violet to a saturated rose at the edges.
func plinkoGradientRGB(x int) [3]int {
	stops := [4][3]int{{215, 255, 255}, {85, 195, 255}, {145, 85, 245}, {255, 0, 95}}
	distance := math.Abs(float64(x-34)) / 34
	segment := min(2, int(distance*3))
	blend := distance*3 - float64(segment)
	var rgb [3]int
	for i := range rgb {
		rgb[i] = int(math.Round(float64(stops[segment][i])*(1-blend) + float64(stops[segment+1][i])*blend))
	}
	return rgb
}

func plinkoColorAt(x int, scale float64) int {
	rgb := plinkoGradientRGB(x)
	return color.NearestXterm256(
		max(0, min(255, int(float64(rgb[0])*scale))),
		max(0, min(255, int(float64(rgb[1])*scale))),
		max(0, min(255, int(float64(rgb[2])*scale))))
}

// plinkoHintRow is where the pocket reminders are overlaid. It sits two rows
// below the midpoint so the letters clear the payout legend, which only runs
// down to the midpoint row.
const plinkoHintRow = plinkoRows/2 + 2

const plinkoHintColor = 238

// Foregrounds are deliberately faint so the balls stay the brightest thing
// on the board.
func plinkoPegColor(x int) int    { return plinkoColorAt(x, 0.68) }
func plinkoBorderColor(x int) int { return plinkoColorAt(x, 0.78) }
func plinkoPocketColor(x int) int { return plinkoColorAt(x, 0.90) }

func plinkoMultiplier(multiplier float64) string {
	return strings.TrimSuffix(fmt.Sprintf("%.1f", multiplier), ".0") + "x"
}

type plinkoCell struct {
	glyph rune
	fg    int
	guide bool
}
type plinkoLine [plinkoWidth]plinkoCell

func newPlinkoLine() plinkoLine {
	var line plinkoLine
	for x := range line {
		line[x] = plinkoCell{glyph: ' ', fg: -1}
	}
	return line
}
func (line *plinkoLine) text(x int, text string, fg int) {
	for _, c := range text {
		if x >= len(line) {
			break
		}
		line[x] = plinkoCell{glyph: c, fg: fg}
		x++
	}
}
func (line plinkoLine) render(mode string) string {
	var b strings.Builder
	// Paint contiguous runs, not individual cells. This keeps the same fixed
	// canvas without flooding a slow MUD connection with redundant resets.
	for x := 0; x < len(line); {
		cell := line[x]
		end := x + 1
		for end < len(line) && line[end].fg == cell.fg && line[end].guide == cell.guide {
			end++
		}
		var text strings.Builder
		for _, c := range line[x:end] {
			text.WriteRune(c.glyph)
		}
		fg, dim := cell.fg, cell.guide && mode == "ansi"
		if dim {
			fg = 7 // Basic ANSI approximates the faint gray with dim white.
		}
		b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Dim: dim}, text.String()))
		x = end
	}
	return b.String()
}

type plinkoLegendEntry struct {
	label rune
	text  string
	// pocket is the left-most pocket index that carries this label, used to
	// tint the entry with that column's board hue.
	pocket int
}

// plinkoLegendEntries is one entry per distinct payout, outside-to-center.
// Mirrored and rounded-tie pockets share a letter and therefore one entry.
func plinkoLegendEntries(s *casino.PlinkoSnapshot) []plinkoLegendEntry {
	labels := casino.PlinkoPocketLabels(s.Pockets)
	var entries []plinkoLegendEntry
	seen := map[rune]bool{}
	for k, label := range labels {
		if seen[label] {
			continue
		}
		seen[label] = true
		entries = append(entries, plinkoLegendEntry{label: label, text: plinkoMultiplier(s.Pockets[k]), pocket: k})
	}
	return entries
}

// plinkoLegendText pads an entry to a fixed width so a guide letter in the
// next column never butts directly against the multiplier.
func plinkoLegendText(e plinkoLegendEntry) string {
	return fmt.Sprintf("%c %-7s", e.label, e.text)
}

// The standalone paytable is compact text, not another board canvas.
func renderPlinkoPaytable(s *casino.PlinkoSnapshot, mode string) string {
	balls := "balls"
	if s.Total == 1 {
		balls = "ball"
	}
	parts := []string{fmt.Sprintf("%s %d %s %.2f%% RTP:", s.Risk, s.Total, balls, s.RTP)}
	for _, e := range plinkoLegendEntries(s) {
		parts = append(parts, color.Render(mode, color.ColorSpec{Fg: plinkoPocketColor(2 + 4*e.pocket), Bg: -1}, fmt.Sprintf("%c%s", e.label, e.text)))
	}
	return strings.Join(parts, " ")
}

// plinkoOverlayHints prints the pocket reminders across the reminder row. The
// letters sit on the fixed pocket columns (2+4k) so they line up with the base
// rail. Hints only fill empty cells: a falling ball must never disappear.
func plinkoOverlayHints(line *plinkoLine, labels [17]rune) {
	for k, label := range labels {
		x := 2 + 4*k
		if line[x].glyph == ' ' {
			line[x] = plinkoCell{glyph: label, fg: plinkoHintColor, guide: true}
		}
	}
}

// The legend appears down the left, then each snapshot appends one scanline.
// The pocket reminders are overlaid on their own board row, not a line of their
// own.
func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string {
	labels := casino.PlinkoPocketLabels(s.Pockets)
	if s.Paytable {
		return renderPlinkoPaytable(s, mode)
	}
	ball, peg := 'o', '.'
	if unicode {
		ball, peg = '●', '·'
	}
	line := newPlinkoLine()
	left, right := 32-2*s.Row, 36+2*s.Row
	// Pegs sit at their true between-lane positions.
	for k := 1; k <= s.Row; k++ {
		x := left + 4*k
		line[x] = plinkoCell{glyph: peg, fg: plinkoPegColor(x)}
	}
	for k := 0; k <= s.Row; k++ {
		count := s.Counts[k]
		if count == 0 {
			continue
		}
		glyph := ball
		if color.Plain(mode) && count > 1 {
			glyph = rune('0' + count)
			if count == 10 {
				glyph = '*'
			}
		}
		line[left+2+4*k] = plinkoCell{glyph: glyph, fg: plinkoBallColor(count, s.Total)}
	}
	line[left] = plinkoCell{glyph: '/', fg: plinkoBorderColor(left)}
	line[right] = plinkoCell{glyph: '\\', fg: plinkoBorderColor(right)}
	if entries := plinkoLegendEntries(s); s.Row < len(entries) {
		e := entries[s.Row]
		line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket))
	}
	if s.Row == plinkoHintRow {
		plinkoOverlayHints(&line, labels)
	}
	lines := []string{line.render(mode)}
	if s.Row == plinkoRows {
		rail := newPlinkoLine()
		for k, label := range labels {
			x := 1 + 4*k
			left, right := '[', ']'
			fg := plinkoPocketColor(x + 1)
			if s.Counts[k] > 0 {
				left, right = '>', '<'
				fg = plinkoBallColor(s.Counts[k], s.Total)
			}
			rail.text(x, string([]rune{left, label, right}), fg)
		}
		lines = append(lines, rail.render(mode))
	}
	return strings.Join(lines, "\n")
}