aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-29 15:19:40 -0400
committerhistoria <[not public]>2026-09-29 15:19:40 -0400
commitbfe22e10b5b9f2c7039d1d67021229659a06a226 (patch)
treef7c85d40f848e6fe3ec11d8d29eaf078ea4212b7 /internal
parent61fb8e950e0b30ef0ee65aa5882545e5292d9112 (diff)
downloadthehouseoficarus-bfe22e10b5b9f2c7039d1d67021229659a06a226.tar.gz
feat: better wheel graphics, better pocket graphics
Diffstat (limited to 'internal')
-rw-r--r--internal/game/render_roulette.go211
-rw-r--r--internal/game/render_roulette_test.go115
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"} {