aboutsummaryrefslogtreecommitdiff
path: root/internal/game/render_plinko.go
blob: 17f533a3fecb7e6e1ba10fc606fcdb25a6d3dd7b (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
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))))
}

// plinkoHintColor is the dim white used by the one-time midpoint pocket ruler.
func plinkoHintColor() int { return 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
	for _, cell := range line {
		fg := cell.fg
		// Basic ANSI has no dim white; the dim attribute plus bright black is
		// its closest readable stand-in for a faint white hint.
		dim := false
		if cell.guide && mode == "ansi" {
			fg, dim = 7, true
		}
		b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Dim: dim}, string(cell.glyph)))
	}
	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 legend is written down the left, outside the pyramid, one entry per
// row, so every value is known well before the ball reaches the pockets.
func renderPlinkoLegend(s *casino.PlinkoSnapshot, mode string) []string {
	var lines []string
	for _, e := range plinkoLegendEntries(s) {
		line := newPlinkoLine()
		line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket))
		lines = append(lines, line.render(mode))
	}
	return lines
}

// plinkoHintLine is the one-time pocket ruler printed at the board's midpoint,
// so players learn the pocket columns without a letter on every row.
func plinkoHintLine(labels [17]rune, mode string) string {
	hint := newPlinkoLine()
	for k, label := range labels {
		hint[2+4*k] = plinkoCell{glyph: label, fg: plinkoHintColor(), guide: true}
	}
	return hint.render(mode)
}

// The legend appears down the left, then each snapshot appends one scanline.
// The pocket hints print once, as a ruler at the board's midpoint.
func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string {
	labels := casino.PlinkoPocketLabels(s.Pockets)
	if s.Paytable {
		title := "=== P L I N K O / " + strings.ToUpper(s.Risk) + " ==="
		heading := newPlinkoLine()
		heading.text((plinkoWidth-len(title))/2, title, plinkoPocketColor(34))
		note := newPlinkoLine()
		note.text(1, "Matching letters share a payout; returns include the stake.", plinkoPocketColor(34))
		lines := []string{heading.render(mode), note.render(mode)}
		lines = append(lines, renderPlinkoLegend(s, mode)...)
		tail := newPlinkoLine()
		tail.text(1, "White = one ball; shades to red = all balls overlapping.", plinkoPocketColor(34))
		return strings.Join(append(lines, tail.render(mode)), "\n")
	}
	ball, peg := 'o', '.'
	if unicode {
		ball, peg = '●', '·'
	}
	line := newPlinkoLine()
	left, right := 32-2*s.Row, 36+2*s.Row
	line[left] = plinkoCell{glyph: '/', fg: plinkoBorderColor(left)}
	line[right] = plinkoCell{glyph: '\\', fg: plinkoBorderColor(right)}
	// Pegs sit at their true between-lane positions; they are never moved to
	// make room for a guide letter.
	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 mode == "none" && count > 1 {
			glyph = rune('0' + count)
			if count == 10 {
				glyph = '*'
			}
		}
		line[left+2+4*k] = plinkoCell{glyph: glyph, fg: plinkoBallColor(count, s.Total)}
	}
	if entries := plinkoLegendEntries(s); s.Row < len(entries) {
		e := entries[s.Row]
		line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket))
	}
	lines := []string{line.render(mode)}
	if s.Row == plinkoRows/2 {
		lines = append(lines, plinkoHintLine(labels, mode))
	}
	if s.Row == 16 {
		rail := newPlinkoLine()
		for k, label := range labels {
			x := 1 + 4*k
			rail[x] = plinkoCell{glyph: '[', fg: plinkoPegColor(x)}
			rail[x+1] = plinkoCell{glyph: label, fg: plinkoPocketColor(x + 1)}
			rail[x+2] = plinkoCell{glyph: ']', fg: plinkoPegColor(x + 2)}
		}
		lines = append(lines, rail.render(mode))
	}
	return strings.Join(lines, "\n")
}