diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/render_roulette.go | 211 | ||||
| -rw-r--r-- | internal/game/render_roulette_test.go | 115 |
2 files changed, 232 insertions, 94 deletions
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index 4861939..cfded31 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -2,6 +2,8 @@ package game import ( "fmt" + "math" + "sort" "strconv" "strings" @@ -192,54 +194,16 @@ func renderRouletteLayout(double bool, mode string, unicode bool) string { 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. +// renderRouletteWheel draws a representative spinning wheel. There are no +// numbers: only the rim carries solid red and black pockets, alternating like +// a real wheel, with one green zero (plus its diametrically opposite pocket on +// American wheels). A simple plus marks the croupier's spindle. Without color +// the pockets fall back to R/B/G so the wheel still reads. func renderRouletteWheel(wheel []string, mode string, unicode bool) string { double := false for _, p := range wheel { @@ -247,14 +211,13 @@ func renderRouletteWheel(wheel []string, mode string, unicode bool) string { 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 + const width, height = 17, 9 + // Half-widths are hand-rounded so the 17x9 grid reads as a circle once the + // terminal's roughly 2:1 character cells are taken into account. The top + // and bottom rows stay wide instead of collapsing to a single cell. + halfWidths := [height]int{2, 5, 7, 8, 8, 8, 7, 5, 2} + cx, cy := float64(width-1)/2, float64(height-1)/2 + rx, ry := cx, cy grid := make([][]rouletteCell, height) for y := range grid { grid[y] = make([]rouletteCell, width) @@ -262,27 +225,114 @@ func renderRouletteWheel(wheel []string, mode string, unicode bool) string { 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} + plain := mode == "none" || mode == "" + pocketRune := func(bg int) rune { + if !plain { + return ' ' + } + switch bg { + case 1: + return 'R' + case 2: + return 'G' + default: + return 'B' + } + } + // Collect the rim cells in angular order so the pockets can alternate + // around the circumference instead of filling the disc with wedges. + type rimCell struct { + x, y int + ang float64 + } + var rim []rimCell + for y := 0; y < height; y++ { + hw := halfWidths[y] + for x := int(cx) - hw; x <= int(cx)+hw; x++ { + nx := (float64(x) - cx) / rx + ny := (float64(y) - cy) / ry + if nx*nx+ny*ny >= 0.75 { + rim = append(rim, rimCell{x, y, math.Atan2(ny, nx)}) } } } - for i, value := range wheel { - if i >= len(slots) { + sort.Slice(rim, func(i, j int) bool { + if rim[i].ang != rim[j].ang { + return rim[i].ang < rim[j].ang + } + return rim[i].x < rim[j].x + }) + // The top and bottom rows hold an odd number of cells, so anchor the walk + // there and leave those centre pockets one wide while every other pocket + // takes two adjacent rim cells. + topIdx, botIdx := -1, -1 + for i, c := range rim { + switch { + case c.x == int(cx) && c.y == 0: + topIdx = i + case c.x == int(cx) && c.y == height-1: + botIdx = i + } + } + pocketIndex := make([]int, len(rim)) + cur := 0 + pocketIndex[topIdx] = cur + for i := (topIdx + 1) % len(rim); i != topIdx; { + cur++ + pocketIndex[i] = cur + if i == botIdx { + i = (i + 1) % len(rim) + continue + } + i = (i + 1) % len(rim) + pocketIndex[i] = cur + i = (i + 1) % len(rim) + } + pockets := cur + 1 + greenPocket := 0 + for i, pocket := range wheel { + if pocket == "0" && len(wheel) > 0 { + greenPocket = i * pockets / len(wheel) break } - bg, label := roulettePocketColor(value) - place(slots[i][0]-1, slots[i][1], roulettePocketToken(value, label, mode), bg) } - hub := []string{"\\ | /", " \\ | / ", "--( o )--", " / | \\ ", "/ | \\"} + opposite := (greenPocket + pockets/2) % pockets + for i, c := range rim { + p := pocketIndex[i] + bg := 1 + if p%2 == 1 { + bg = 0 + } + if p == greenPocket || (double && p == opposite) { + bg = 2 + } + grid[c.y][c.x] = rouletteCell{pocketRune(bg), bg} + } + // The croupier spins the wheel by its central spindle: a simple plus. + plus := []string{ + " | ", + " | ", + "---+---", + " | ", + " | ", + } if unicode { - hub = []string{"╲ │ ╱", " ╲ │ ╱ ", "──( ● )──", " ╱ │ ╲ ", "╱ │ ╲"} + plus = []string{ + " │ ", + " │ ", + "───┼───", + " │ ", + " │ ", + } } - for k, text := range hub { - place(29, 9+k, text, -1) + baseX, baseY := int(cx)-len([]rune(plus[0]))/2, int(cy)-len(plus)/2 + for dy, row := range plus { + for dx, r := range []rune(row) { + if r == ' ' { + continue + } + grid[baseY+dy][baseX+dx] = rouletteCell{r, -1} + } } lines := make([]string, 0, height) for _, row := range grid { @@ -293,7 +343,7 @@ func renderRouletteWheel(wheel []string, mode string, unicode bool) string { func renderRouletteRow(row []rouletteCell, mode string) string { last := len(row) - for last > 0 && row[last-1].r == ' ' { + for last > 0 && row[last-1].r == ' ' && row[last-1].bg < 0 { last-- } if last == 0 { @@ -320,8 +370,10 @@ func renderRouletteRow(row []rouletteCell, mode string) 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. +// neighbours at playing-card size. The pocket number sits on the top row, a +// rule separates it from the ball (one row above centre), and the color is +// spelled out on the bottom row only when color is off. There is no +// surrounding prose. func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int) string { size = cardRenderSize(unicode, size) if size < 5 { @@ -333,28 +385,37 @@ func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int if unicode { ball = "●" } + // The rule needs a spare row, so the smallest cards put the ball directly + // under the number instead. + const numberRow, ruleRow = 1, 2 + ballRow := size / 2 + hasRule := ballRow > ruleRow + colorRow := size - 2 + plain := mode == "none" || mode == "" 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: + switch { + case row == 0: text = b.tl + strings.Repeat(b.h, width-2) + b.tr - case size - 1: + case row == size-1: text = b.bl + strings.Repeat(b.h, width-2) + b.br + case hasRule && row == ruleRow: + text = b.teeLeft + strings.Repeat(b.h, width-2) + b.teeRight default: content := "" - if row == 1 { - content = name - } - if row == size/2 { + if row == numberRow { content = pocket } - if i == 1 && row == size/2+1 { + if i == 1 && row == ballRow { content = ball } + if plain && row == colorRow { + content = name + } text = b.v + rouletteCenter(content, width-2) + b.v } cells = append(cells, roulettePaint(mode, text, bg)) diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index c1065e5..b1a6005 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -97,28 +97,30 @@ func TestRouletteArtModes(t *testing.T) { wheelArt := renderRouletteWheel(wheelOrder, mode, unicode) plainWheel := plainRoulette(wheelArt) lines := strings.Split(plainWheel, "\n") - if len(lines) != 23 { - t.Fatalf("wheel height = %d, want 23", len(lines)) + if len(lines) != 9 { + t.Fatalf("wheel height = %d, want 9", len(lines)) } - wantDoubleZeroLabel := 0 - if double { - wantDoubleZeroLabel = 1 - } - if got := strings.Count(plainWheel, "00"); got != wantDoubleZeroLabel { - t.Fatalf("double-zero labels = %d, want %d", got, wantDoubleZeroLabel) + if strings.ContainsAny(plainWheel, "0123456789") { + t.Fatalf("wheel should not show numbers:\n%s", plainWheel) } - if mode != "none" { - wantGreen := 1 - if double { - wantGreen = 2 + if mode == "none" || mode == "" { + for _, r := range "RBG" { + if !strings.ContainsRune(plainWheel, r) { + t.Fatalf("colorless wheel missing %q:\n%s", r, plainWheel) + } } - greens := strings.Count(wheelArt, color.BgCode(mode, 2)) - if greens != wantGreen { - t.Fatalf("green pockets = %d, want %d", greens, wantGreen) + // The top and bottom rows must be several cells wide so the + // disc reads as a circle rather than a pointed oval. + for _, idx := range []int{0, 8} { + if got := len(strings.TrimSpace(lines[idx])); got != 5 { + t.Fatalf("wheel row %d = %d wide, want 5:\n%s", idx, got, plainWheel) + } } - total := greens + strings.Count(wheelArt, color.BgCode(mode, 0)) + strings.Count(wheelArt, color.BgCode(mode, 1)) - if total != len(wheelOrder) { - t.Fatalf("drawn pockets = %d, want %d", total, len(wheelOrder)) + } else { + for _, bg := range []int{0, 1, 2} { + if !strings.Contains(wheelArt, color.BgCode(mode, bg)) { + t.Fatalf("wheel missing pocket background %d", bg) + } } } @@ -134,6 +136,21 @@ func TestRouletteArtModes(t *testing.T) { } } +// TestRouletteWheelGreenPockets checks that the single-zero wheel has one green +// pocket and the American wheel adds the diametrically opposite one. +func TestRouletteWheelGreenPockets(t *testing.T) { + single := plainRoulette(renderRouletteWheel(casino.WheelOrder(false), "none", true)) + double := plainRoulette(renderRouletteWheel(casino.WheelOrder(true), "none", true)) + singleGreen := strings.Count(single, "G") + doubleGreen := strings.Count(double, "G") + if singleGreen == 0 { + t.Fatalf("single-zero wheel has no green:\n%s", single) + } + if doubleGreen <= singleGreen { + t.Fatalf("double-zero green = %d, want more than single-zero %d:\n%s", doubleGreen, singleGreen, double) + } +} + // TestRouletteLayoutConnections guards the box-drawing joints: the dozen-row // boxes must connect to the numbered grid above and be closed under, the // row-bet cells must be boxed on the right, and the bottom rule must tee under @@ -197,7 +214,7 @@ func TestRouletteResultSizesAndNeighbors(t *testing.T) { t.Fatalf("unequal pocket widths: %q", line) } } - center := []rune(lines[height/2]) + center := []rune(lines[1]) if !strings.Contains(string(center[:width]), "1") || !strings.Contains(string(center[width+1:2*width+1]), "00") || !strings.Contains(string(center[2*width+2:]), "27") { t.Fatal("wrong pocket order", art) } @@ -212,6 +229,66 @@ func TestRouletteResultSizesAndNeighbors(t *testing.T) { } } +// TestRouletteResultLayout checks the new card shape: number on top, a rule +// under it, the ball one row above centre, and the color spelled out on the +// bottom row only when color is off. cardRenderSize caps non-Unicode cards at +// five rows, so the full nine-row shape is exercised through Unicode. +func TestRouletteResultLayout(t *testing.T) { + pockets := [3]string{"1", "10", "27"} // RED, BLACK, RED + for _, tc := range []struct { + unicode bool + size int + height int + }{ + {true, 9, 9}, + {true, 7, 7}, + {true, 5, 5}, + {false, 9, 5}, + } { + unicode, height := tc.unicode, tc.height + ball := "o" + rule := "-" + if unicode { + ball = "●" + rule = "─" + } + ballRow := height / 2 + colorRow := height - 2 + hasRule := ballRow > 2 + + colored := plainRoulette(renderRouletteResult(pockets, "xterm256", unicode, tc.size)) + lines := strings.Split(colored, "\n") + if len(lines) != height { + t.Fatalf("unicode=%v size=%d: height=%d, want %d", unicode, tc.size, len(lines), height) + } + for i, want := range pockets { + if !strings.Contains(lines[1], want) { + t.Fatalf("unicode=%v size=%d: top row missing pocket %d (%s):\n%s", unicode, tc.size, i, want, colored) + } + } + if hasRule { + if !strings.Contains(lines[2], rule) { + t.Fatalf("unicode=%v size=%d: missing rule under the number:\n%s", unicode, tc.size, colored) + } + } + if !strings.Contains(lines[ballRow], ball) { + t.Fatalf("unicode=%v size=%d: ball should be on row %d:\n%s", unicode, tc.size, ballRow, colored) + } + if strings.Contains(colored, "RED") || strings.Contains(colored, "BLACK") { + t.Fatalf("unicode=%v size=%d: color label shown while color is on:\n%s", unicode, tc.size, colored) + } + + plain := plainRoulette(renderRouletteResult(pockets, "none", unicode, tc.size)) + plainLines := strings.Split(plain, "\n") + if strings.Contains(plainLines[1], "RED") || strings.Contains(plainLines[1], "BLACK") { + t.Fatalf("unicode=%v size=%d: color label should not be on the top row:\n%s", unicode, tc.size, plain) + } + if !strings.Contains(plainLines[colorRow], "RED") || !strings.Contains(plainLines[colorRow], "BLACK") { + t.Fatalf("unicode=%v size=%d: missing color label on row %d:\n%s", unicode, tc.size, colorRow, plain) + } + } +} + func TestRouletteEventColorsDoNotWrapArt(t *testing.T) { for _, public := range []bool{false, true} { for _, mode := range []string{"none", "ansi", "xterm256"} { |
