diff options
Diffstat (limited to 'internal/game/render_plinko.go')
| -rw-r--r-- | internal/game/render_plinko.go | 213 |
1 files changed, 213 insertions, 0 deletions
diff --git a/internal/game/render_plinko.go b/internal/game/render_plinko.go new file mode 100644 index 0000000..17f533a --- /dev/null +++ b/internal/game/render_plinko.go @@ -0,0 +1,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") +} |
