aboutsummaryrefslogtreecommitdiff
path: root/internal/game/render_pachinko.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/render_pachinko.go')
-rw-r--r--internal/game/render_pachinko.go404
1 files changed, 404 insertions, 0 deletions
diff --git a/internal/game/render_pachinko.go b/internal/game/render_pachinko.go
new file mode 100644
index 0000000..e973f04
--- /dev/null
+++ b/internal/game/render_pachinko.go
@@ -0,0 +1,404 @@
+package game
+
+import (
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/ui"
+)
+
+// The rectangle is drawn on a fixed pitch: eight lanes at x = 3+6k, pegs on
+// the boundaries at x = 6+6k, and walls at both ends. Every scanline has the
+// same width, so a drop prints one row per tick and the ball's path reads as a
+// diagonal column of frames. The right wall sits one half-lane past the last
+// lane, mirroring the left wall, so the outer slots have the same clearance on
+// both sides.
+const (
+ pachinkoLanes = 8
+ pachinkoRows = 20
+ pachinkoPitch = 6
+ pachinkoWidth = pachinkoLanes*pachinkoPitch + 1
+ pachinkoBallX = 3
+ pachinkoPegGap = 6
+)
+
+// pachinkoLaneX is the fixed column of a lane on an even scanline; the prize
+// row and the top labels use it so a settled ball lines up with its pocket.
+func pachinkoLaneX(lane int) int { return pachinkoBallX + pachinkoPitch*lane }
+
+// pachinkoDropX staggers the ball's column on odd rows so a ball that holds its
+// lane visibly weaves between the pins instead of sliding down a static grid.
+func pachinkoDropX(row, lane int) int {
+ if row%2 == 0 {
+ return pachinkoLaneX(lane)
+ }
+ return pachinkoPitch * (lane + 1)
+}
+
+// pachinkoGradientRGB gives the board a warm jewel ramp: deep violet walls,
+// through blue and teal, to a golden-red right edge.
+func pachinkoGradientRGB(x int) [3]int {
+ stops := [5][3]int{{150, 80, 255}, {60, 170, 255}, {40, 230, 190}, {255, 210, 60}, {255, 90, 70}}
+ distance := float64(x) / float64(pachinkoWidth-1)
+ segment := min(3, int(distance*4))
+ blend := distance*4 - 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 pachinkoColorAt(x int, scale float64) int {
+ rgb := pachinkoGradientRGB(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))))
+}
+
+func pachinkoPegColor(x int) int { return pachinkoColorAt(x, 0.45) }
+func pachinkoWallColor(x int) int { return pachinkoColorAt(x, 0.85) }
+func pachinkoHeaderColor(x int) int { return pachinkoColorAt(x, 1.0) }
+
+// pachinkoPocketColor maps a prize to a readable color; doubling shifts an
+// already-valuable pocket hotter.
+func pachinkoPocketColor(p casino.PachinkoPocket, doubling int) int {
+ switch {
+ case p.Double:
+ if doubling > 0 {
+ return 201 // bright magenta
+ }
+ return 198 // magenta
+ case p.Value >= 100:
+ return 51 // neon cyan
+ case p.Value >= 10:
+ if doubling > 0 {
+ return 226
+ }
+ return 220 // gold
+ case p.Value >= 5:
+ return 208 // orange
+ case p.Value >= 2:
+ return 46 // green
+ case p.Value >= 1:
+ return 49 // teal: break-even and small fractions
+ case p.Value > 0:
+ return 245 // dim fraction
+ default:
+ return 240 // dim slate for 0x
+ }
+}
+
+func pachinkoPocketBold(p casino.PachinkoPocket, doubling int) bool {
+ return !p.Double && (p.Value >= 2 || doubling > 0)
+}
+
+type pachinkoCell struct {
+ glyph rune
+ fg int
+ bold bool
+}
+
+type pachinkoLine [pachinkoWidth]pachinkoCell
+
+func newPachinkoLine() pachinkoLine {
+ var line pachinkoLine
+ for x := range line {
+ line[x] = pachinkoCell{glyph: ' ', fg: -1}
+ }
+ return line
+}
+
+func (line *pachinkoLine) put(x int, glyph rune, fg int) {
+ if x >= 0 && x < len(line) {
+ line[x] = pachinkoCell{glyph: glyph, fg: fg}
+ }
+}
+
+func (line *pachinkoLine) putBold(x int, glyph rune, fg int) {
+ if x >= 0 && x < len(line) {
+ line[x] = pachinkoCell{glyph: glyph, fg: fg, bold: true}
+ }
+}
+
+// centeredStart returns the first cell of a text of length n centered in the
+// 6-wide lane k cell.
+func pachinkoCenteredStart(lane, n int) int {
+ if n > pachinkoPitch {
+ n = pachinkoPitch
+ }
+ return pachinkoPitch*lane + (pachinkoPitch-n+1)/2
+}
+
+func (line pachinkoLine) render(mode string) string {
+ var b strings.Builder
+ for x := 0; x < len(line); {
+ cell := line[x]
+ end := x + 1
+ for end < len(line) && line[end].fg == cell.fg && line[end].bold == cell.bold {
+ end++
+ }
+ var text strings.Builder
+ for _, c := range line[x:end] {
+ text.WriteRune(c.glyph)
+ }
+ b.WriteString(color.Render(mode, color.ColorSpec{Fg: cell.fg, Bg: -1, Bold: cell.bold}, text.String()))
+ x = end
+ }
+ return b.String()
+}
+
+func pachinkoGlyphs(unicode bool) (ball, peg rune) {
+ if unicode {
+ return '●', '·'
+ }
+ return 'o', '.'
+}
+
+// pachinkoScanline draws one staggered peg row, optionally with the ball in a
+// lane.
+func pachinkoScanline(row, ballLane int, mode string, unicode bool) string {
+ ball, peg := pachinkoGlyphs(unicode)
+ line := newPachinkoLine()
+ line.put(0, '|', pachinkoWallColor(0))
+ line.put(pachinkoWidth-1, '|', pachinkoWallColor(pachinkoWidth-1))
+ if row%2 == 0 {
+ for k := 0; k < pachinkoLanes-1; k++ {
+ x := pachinkoPegGap + pachinkoPitch*k
+ line.put(x, peg, pachinkoPegColor(x))
+ }
+ } else {
+ for k := 0; k < pachinkoLanes; k++ {
+ x := pachinkoBallX + pachinkoPitch*k
+ line.put(x, peg, pachinkoPegColor(x))
+ }
+ }
+ if ballLane >= 0 && ballLane < pachinkoLanes {
+ line.putBold(pachinkoDropX(row, ballLane), ball, 231)
+ }
+ return line.render(mode)
+}
+
+// pachinkoPocketLabel is the text shown for a landed value after doublings.
+func pachinkoPocketLabel(p casino.PachinkoPocket, doubling int) string {
+ if p.Double {
+ return "DBL"
+ }
+ value := p.Value
+ for i := 0; i < doubling && i < 62; i++ {
+ value *= 2
+ if value > casino.MaxCredits {
+ return "MAX"
+ }
+ }
+ if value > casino.MaxCredits {
+ return "MAX"
+ }
+ return strconv.FormatFloat(value, 'f', -1, 64) + "x"
+}
+
+// pachinkoPocketRow prints the prize row, marking the landed pocket.
+func pachinkoPocketRow(s *casino.PachinkoSnapshot, mode string) string {
+ var b strings.Builder
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ pocket := s.Pockets[lane]
+ label := pachinkoPocketLabel(pocket, s.Doubling)
+ landed := s.Landing == lane && (s.Phase == "result" || s.Phase == "dropping")
+ text := label
+ if landed && len([]rune("["+label+"]")) <= pachinkoPitch {
+ text = "[" + label + "]"
+ }
+ fg := pachinkoPocketColor(pocket, s.Doubling)
+ bold := pachinkoPocketBold(pocket, s.Doubling)
+ if landed {
+ bold = true
+ switch {
+ case pocket.Double:
+ fg = 201
+ case pocket.Value >= 100:
+ fg = 51
+ default:
+ fg = 226
+ }
+ }
+ runes := []rune(text)
+ if len(runes) > pachinkoPitch {
+ runes = runes[:pachinkoPitch]
+ }
+ lead := pachinkoCenteredStart(lane, len(runes)) - pachinkoPitch*lane
+ if lead < 0 {
+ lead = 0
+ }
+ trail := pachinkoPitch - lead - len(runes)
+ if trail < 0 {
+ trail = 0
+ }
+ b.WriteString(strings.Repeat(" ", lead))
+ b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: bold}, string(runes)))
+ b.WriteString(strings.Repeat(" ", trail))
+ }
+ // The cells span pachinkoLanes*pachinkoPitch; pad to the board width.
+ b.WriteString(strings.Repeat(" ", pachinkoWidth-pachinkoLanes*pachinkoPitch))
+ return b.String()
+}
+
+// renderPachinkoBoard draws the whole rectangle: the numbered top slots, the
+// peg rows, and the prize row. The labels sit in the gaps of the first peg row
+// (not on its pins) so they read as slots to drop the ball into. The first
+// drawn row is the even entry row, whose pins bracket each slot number.
+func renderPachinkoBoard(s *casino.PachinkoSnapshot, mode string, unicode bool) string {
+ lines := make([]string, 0, pachinkoRows+3)
+ header := newPachinkoLine()
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ x := pachinkoLaneX(lane)
+ header.put(x, rune('1'+lane), pachinkoHeaderColor(x))
+ }
+ lines = append(lines, header.render(mode))
+ for row := 0; row <= pachinkoRows; row++ {
+ lines = append(lines, pachinkoScanline(row, -1, mode, unicode))
+ }
+ lines = append(lines, pachinkoPocketRow(s, mode))
+ return strings.Join(lines, "\n")
+}
+
+// pachinkoBlankPocketRow prints a prize row of question marks for the
+// reference view, where no board has been dealt yet.
+func pachinkoBlankPocketRow(mode string) string {
+ var b strings.Builder
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ x := pachinkoLaneX(lane)
+ fg := pachinkoColorAt(x, 0.6)
+ lead := pachinkoCenteredStart(lane, 1) - pachinkoPitch*lane
+ if lead < 0 {
+ lead = 0
+ }
+ trail := pachinkoPitch - lead - 1
+ if trail < 0 {
+ trail = 0
+ }
+ b.WriteString(strings.Repeat(" ", lead))
+ b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, "?"))
+ b.WriteString(strings.Repeat(" ", trail))
+ }
+ b.WriteString(strings.Repeat(" ", pachinkoWidth-pachinkoLanes*pachinkoPitch))
+ return b.String()
+}
+
+// renderPachinkoBlankBoard draws the numbered top slots, the peg rows, and a
+// row of question marks in place of the pockets.
+func renderPachinkoBlankBoard(mode string, unicode bool) string {
+ lines := make([]string, 0, pachinkoRows+3)
+ header := newPachinkoLine()
+ for lane := 0; lane < pachinkoLanes; lane++ {
+ x := pachinkoLaneX(lane)
+ header.put(x, rune('1'+lane), pachinkoHeaderColor(x))
+ }
+ lines = append(lines, header.render(mode))
+ for row := 0; row <= pachinkoRows; row++ {
+ lines = append(lines, pachinkoScanline(row, -1, mode, unicode))
+ }
+ lines = append(lines, pachinkoBlankPocketRow(mode))
+ return strings.Join(lines, "\n")
+}
+
+// pachinkoOddsColor maps a prize tier to the color its label uses in the table.
+func pachinkoOddsColor(label string) int {
+ switch label {
+ case "1.7x":
+ return 49
+ case "2x":
+ return 46
+ case "5x":
+ return 208
+ case "10x":
+ return 220
+ case "DOUBLE":
+ return 198
+ case "100x":
+ return 51
+ default:
+ return 240
+ }
+}
+
+// renderPachinkoOddsTable lays the board composition out as a bordered table.
+// The chance column carries the exact percentages, so it is blanked unless the
+// player carries a House Edge instrument.
+func renderPachinkoOddsTable(odds []casino.PachinkoPrizeOdds, mode string, unicode, showOdds bool, precision int) string {
+ rows := make([][]string, 0, len(odds))
+ for _, odd := range odds {
+ chance := "?"
+ if showOdds {
+ if odd.Chance > 0 {
+ chance = strconv.FormatFloat(100*odd.Chance, 'f', precision, 64) + "%"
+ } else {
+ chance = "—"
+ }
+ }
+ label := color.Render(mode, color.ColorSpec{Fg: pachinkoOddsColor(odd.Label), Bg: -1, Bold: odd.Label == "DOUBLE" || odd.Label == "100x"}, odd.Label)
+ rows = append(rows, []string{label, chance, odd.Odds})
+ }
+ table := ui.Table{
+ Title: "Pachinko pay table",
+ Columns: []string{"Prize", "Chance", "Odds"},
+ Rows: rows,
+ }
+ return strings.Join(table.Render(unicode, 0), "\n")
+}
+
+// pachinkoIntro is the framing text every player sees, whether they are just
+// sitting down or reading the table reference. It is never gated by items.
+func pachinkoIntro(mode string) string {
+ return color.Render(mode, color.ColorSpec{Fg: 253, Bg: -1}, "You stand atop a giant peg board with 8 slots and 20 rows. Win multipliers are randomized as soon as you place a bet.")
+}
+
+// renderPachinkoReference is the table command's view: the framing text, a
+// blank board, and the pay table. The RTP footer appears only with a House Edge
+// instrument, matching the gated chance column.
+func renderPachinkoReference(s *casino.PachinkoSnapshot, mode string, unicode, showOdds bool, precision int) string {
+ parts := []string{
+ pachinkoIntro(mode),
+ "",
+ renderPachinkoBlankBoard(mode, unicode),
+ "",
+ renderPachinkoOddsTable(s.Odds, mode, unicode, showOdds, precision),
+ }
+ if showOdds {
+ format := func(v float64) string { return strconv.FormatFloat(v, 'f', precision, 64) + "%" }
+ parts = append(parts, "", fmt.Sprintf("Best slot: %s RTP Random slot: %s RTP", format(s.RTP), format(s.Uniform)))
+ }
+ return strings.Join(parts, "\n")
+}
+
+// renderPachinko renders the game view: two scanlines per tick while the ball
+// falls, the full board while choosing, the prize row once it settles, and the
+// blank board plus pay table for the table command. showOdds controls whether
+// the exact percentages appear; precision is the House Edge instrument's
+// rounding (0 or 2 decimal places).
+func renderPachinko(s *casino.PachinkoSnapshot, mode string, unicode, showOdds bool, precision int) string {
+ if s == nil {
+ return ""
+ }
+ switch s.Phase {
+ case "paytable":
+ return renderPachinkoReference(s, mode, unicode, showOdds, precision)
+ case "idle":
+ // Sitting down shows the framing text and the menu, but not the blank
+ // board or pay table; those wait for the table command.
+ return pachinkoIntro(mode)
+ case "dropping":
+ return pachinkoScanline(s.Row, s.Lane, mode, unicode)
+ case "result":
+ // The ball path is already on screen; the settled view is just the
+ // prize row so the multipliers land as the last line, with no repeated
+ // board or number header.
+ return pachinkoPocketRow(s, mode)
+ default:
+ return renderPachinkoBoard(s, mode, unicode)
+ }
+}