diff options
| author | historia <[not public]> | 2026-09-29 14:15:06 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-29 14:15:06 -0400 |
| commit | 3db3071c444abb2133e1fcaf7e685095415c1adf (patch) | |
| tree | a1d3c276aa7db2e89ea92a5751a81c197a1e1103 /internal/game/render_roulette.go | |
| parent | 9803facd4368420a5dce3af59c835e13cb015cf5 (diff) | |
| download | thehouseoficarus-3db3071c444abb2133e1fcaf7e685095415c1adf.tar.gz | |
feat: better roulette wheel design
Diffstat (limited to 'internal/game/render_roulette.go')
| -rw-r--r-- | internal/game/render_roulette.go | 333 |
1 files changed, 333 insertions, 0 deletions
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go new file mode 100644 index 0000000..95e91ff --- /dev/null +++ b/internal/game/render_roulette.go @@ -0,0 +1,333 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" +) + +// Roulette art is rendered per recipient, just like cards. Never embed ANSI +// in engine snapshots: spectators may have different color/Unicode settings. +func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode bool, size int) string { + switch s.Phase { + case "playing": + return renderRouletteWheel(s.Wheel, mode, unicode) + case "result": + return renderRouletteResult(s.Pockets, mode, unicode, size) + default: + return renderRouletteLayout(s.DoubleZero, mode, unicode) + } +} + +func rouletteInk(mode, text string) string { + return color.Render(mode, color.ColorSpec{Fg: 15, Bg: -1}, text) +} + +// Use the basic palette so both ANSI-16 and xterm256 give white-on-red, +// white-on-green and white-on-black, rather than approximating a gradient. +func roulettePaint(mode, text string, bg int) string { + return color.Render(mode, color.ColorSpec{Fg: 15, Bg: bg, Bold: true}, text) +} + +func roulettePocketColor(pocket string) (int, string) { + if pocket == "0" || pocket == "00" { + return 2, "GREEN" + } + n, _ := strconv.Atoi(pocket) + switch n { + case 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36: + return 1, "RED" + default: + return 0, "BLACK" + } +} + +func rouletteCenter(text string, width int) string { + pad := max(0, width-color.VisibleLen(text)) + return strings.Repeat(" ", pad/2) + text + strings.Repeat(" ", pad-pad/2) +} + +type rouletteLines struct{ h, v, tl, tr, bl, br, teeTop, teeBottom, teeLeft, teeRight, cross string } + +func rouletteBorders(unicode bool) rouletteLines { + if unicode { + return rouletteLines{"─", "│", "┌", "┐", "└", "┘", "┬", "┴", "├", "┤", "┼"} + } + return rouletteLines{"-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+"} +} + +func repeatRoulette(s string, n int) []string { + parts := make([]string, n) + for i := range parts { + parts[i] = s + } + return parts +} + +// renderRouletteLayout draws the betting board. Green is reserved for the +// 0/00 lane: every other cell is red, black, or plain. The odds reminder is +// not part of the board (it is shown by the "table" command), so the board +// stays compact enough to sit in a betting menu. +func renderRouletteLayout(double bool, mode string, unicode bool) string { + b := rouletteBorders(unicode) + var lines []string + title := "ROULETTE / SINGLE ZERO" + if double { + title = "ROULETTE / DOUBLE ZERO" + } + lines = append(lines, rouletteInk(mode, title)) + // Five columns for the zero lane, twelve four-column pockets. Four + // adjacent pockets line up exactly over each 19-column dozen. + gridRule := func(left, joint, right string) string { + return left + strings.Repeat(b.h, 5) + joint + strings.Join(repeatRoulette(strings.Repeat(b.h, 4), 12), joint) + right + } + lines = append(lines, rouletteInk(mode, gridRule(b.tl, b.teeTop, b.tr))) + for row := 3; row >= 1; row-- { + zero, zeroBg := "", -1 + switch { + case double && row == 3: + zero, zeroBg = "0", 2 + case double && row == 1: + zero, zeroBg = "00", 2 + case !double && row == 2: + zero, zeroBg = "0", 2 + } + if zeroBg == 2 && (mode == "none" || mode == "") { + zero += "G" + } + line := rouletteInk(mode, b.v) + roulettePaint(mode, rouletteCenter(zero, 5), zeroBg) + rouletteInk(mode, b.v) + for n := row; n <= 36; n += 3 { + pocket := strconv.Itoa(n) + bg, name := roulettePocketColor(pocket) + if mode == "none" || mode == "" { + pocket += name[:1] + } + line += roulettePaint(mode, rouletteCenter(pocket, 4), bg) + rouletteInk(mode, b.v) + } + line += rouletteInk(mode, fmt.Sprintf(" column%d", row)) + lines = append(lines, line) + if row > 1 { + // The zero lane spans the grid height; only its labeled cell is green. + line = rouletteInk(mode, b.v+" "+b.teeLeft+strings.Join(repeatRoulette(strings.Repeat(b.h, 4), 12), b.cross)+b.teeRight) + lines = append(lines, line) + } + } + lines = append(lines, rouletteInk(mode, gridRule(b.bl, b.teeBottom, b.br))) + // Outside bets share the exact 60-column numbered-grid footprint. + indent := " " + dozen := indent + rouletteInk(mode, b.v) + for i := 1; i <= 3; i++ { + dozen += rouletteInk(mode, rouletteCenter(fmt.Sprintf("dozen%d", i), 19)) + rouletteInk(mode, b.v) + } + lines = append(lines, dozen) + line := indent + rouletteInk(mode, b.v) + for _, bet := range []string{"low", "even", "red", "black", "odd", "high"} { + bg := -1 + if bet == "red" { + bg = 1 + } + if bet == "black" { + bg = 0 + } + line += roulettePaint(mode, rouletteCenter(bet, 9), bg) + rouletteInk(mode, b.v) + } + lines = append(lines, line, rouletteInk(mode, indent+b.bl+strings.Repeat(b.h, 59)+b.br)) + if mode == "none" || mode == "" { + lines = append(lines, "R = red, B = black, G = green") + } + return strings.Join(lines, "\n") +} + +// rouletteWheelSlots are the pocket anchor points (clockwise from the top) for +// the single-zero ring. The double-zero ring inserts one extra slot at the +// bottom centre. This hand-tuned geometry is what keeps a full 37/38-pocket +// circle readable in a monospace terminal. +var rouletteWheelSlots = [][2]int{ + {25, 0}, {33, 0}, {41, 0}, {46, 1}, {50, 2}, {53, 3}, {57, 4}, {59, 6}, {59, 7}, + {60, 9}, {61, 11}, {60, 13}, {58, 15}, {58, 16}, {56, 18}, {54, 19}, {50, 20}, + {46, 21}, {41, 22}, {33, 22}, {25, 22}, {20, 21}, {17, 20}, {13, 19}, {9, 18}, + {7, 17}, {5, 16}, {4, 14}, {3, 12}, {3, 10}, {4, 8}, {7, 6}, {9, 5}, {12, 4}, + {14, 3}, {17, 2}, {21, 1}, +} + +func rouletteWheelSlotsFor(double bool) [][2]int { + slots := append([][2]int(nil), rouletteWheelSlots...) + if !double { + return slots + } + insert := 20 // between {33,22} and {25,22} + slots = append(slots, [2]int{}) + copy(slots[insert+1:], slots[insert:]) + slots[insert] = [2]int{29, 22} + return slots +} + +// roulettePocketToken renders one three-column pocket. With color it is just +// the number; without color a trailing letter keeps red/black/green distinct. +func roulettePocketToken(value, label, mode string) string { + if mode == "none" || mode == "" { + switch len(value) { + case 1: + return value + label[:1] + " " + case 2: + return value + label[:1] + default: + return value + } + } + return rouletteCenter(value, 3) +} + +type rouletteCell struct { + r rune + bg int +} + +// renderRouletteWheel draws a large circular wheel. The pocket count carries +// the real zero count (one green for single zero, two for American), and the +// hub shows the ball during the spin. +func renderRouletteWheel(wheel []string, mode string, unicode bool) string { + double := false + for _, p := range wheel { + if p == "00" { + double = true + } + } + slots := rouletteWheelSlotsFor(double) + if len(wheel) != len(slots) { + slots = rouletteWheelSlots + if len(wheel) > len(slots) { + wheel = wheel[:len(slots)] + } + } + const width, height = 68, 23 + grid := make([][]rouletteCell, height) + for y := range grid { + grid[y] = make([]rouletteCell, width) + for x := range grid[y] { + grid[y][x] = rouletteCell{' ', -1} + } + } + place := func(x, y int, s string, bg int) { + for k, r := range []rune(s) { + px := x + k + if px >= 0 && px < width && y >= 0 && y < height { + grid[y][px] = rouletteCell{r, bg} + } + } + } + for i, value := range wheel { + if i >= len(slots) { + break + } + bg, label := roulettePocketColor(value) + place(slots[i][0]-1, slots[i][1], roulettePocketToken(value, label, mode), bg) + } + hub := []string{"\\ | /", " \\ | / ", "--( o )--", " / | \\ ", "/ | \\"} + if unicode { + hub = []string{"╲ │ ╱", " ╲ │ ╱ ", "──( ● )──", " ╱ │ ╲ ", "╱ │ ╲"} + } + for k, text := range hub { + place(29, 9+k, text, -1) + } + lines := make([]string, 0, height) + for _, row := range grid { + lines = append(lines, renderRouletteRow(row, mode)) + } + return strings.Join(lines, "\n") +} + +func renderRouletteRow(row []rouletteCell, mode string) string { + last := len(row) + for last > 0 && row[last-1].r == ' ' { + last-- + } + if last == 0 { + return "" + } + var sb strings.Builder + for i := 0; i < last; { + bg := row[i].bg + j := i + var seg []rune + for j < last && row[j].bg == bg { + seg = append(seg, row[j].r) + j++ + } + text := string(seg) + if bg >= 0 && mode != "none" && mode != "" { + sb.WriteString(roulettePaint(mode, text, bg)) + } else { + sb.WriteString(rouletteInk(mode, text)) + } + i = j + } + return sb.String() +} + +// renderRouletteResult draws the winning pocket and its two physical +// neighbours at playing-card size. The ball marks the middle pocket; there is +// no surrounding prose. +func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int) string { + size = cardRenderSize(unicode, size) + if size < 5 { + size = 5 + } + width := size + 2 + b := rouletteBorders(unicode) + ball := "o" + if unicode { + ball = "●" + } + var lines []string + for row := 0; row < size; row++ { + var cells []string + for i, pocket := range pockets { + bg, name := roulettePocketColor(pocket) + var text string + switch row { + case 0: + text = b.tl + strings.Repeat(b.h, width-2) + b.tr + case size - 1: + text = b.bl + strings.Repeat(b.h, width-2) + b.br + default: + content := "" + if row == 1 { + content = name + } + if row == size/2 { + content = pocket + } + if i == 1 && row == size/2+1 { + content = ball + } + text = b.v + rouletteCenter(content, width-2) + b.v + } + cells = append(cells, roulettePaint(mode, text, bg)) + } + lines = append(lines, strings.Join(cells, " ")) + } + return strings.Join(lines, "\n") +} + +// rouletteReminder is the odds/cheat-sheet block, shown only by "table" (and +// "table rules"), never with the ambient betting board. +func (g *Game) rouletteReminder(sess *net.Session) string { + text := "Numbers 35:1 | Dozens / columns 2:1 | Other outside bets 1:1\n" + + "Low: 1-18 | High: 19-36 | Zero loses all outside bets\n" + + "bet 10 on 17 / bet 10 on red / bet 10 on dozen2" + return g.colorize(sess, "casino_action", text) +} + +func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string { + rules := g.colorize(sess, "casino_action", cfg.RulesText()) + if cfg.Game == casino.GameRoulette { + snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)} + rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess) + } + return rules +} |
