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