aboutsummaryrefslogtreecommitdiff
path: root/internal/game/render_plinko.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/render_plinko.go')
-rw-r--r--internal/game/render_plinko.go213
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")
+}