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")
}
|