clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

add mouse to lights out and shikaku

Dami committed 553f7e3
commit
553f7e3eed102e41590caaa4f4123f727d0e1bf9
parent
93913b96d55554cb57417365554eb5cea9c3792c
author
Dami <dami.etoile@gmail.com>
10 files changed, +531 -16
Mlightsout/Model.go13++
Mlightsout/help.md8+
Mlightsout/style.go47+++++++-
Mnonogram/help.md15++-
Mshikaku/Model.go198++++++++++++++++++++++++++++++
Mshikaku/help.md18++-
Ashikaku/mouse.go94++++++++++++++
Mshikaku/puzzle.go11++
Mshikaku/shikaku_test.go115+++++++++++++++++
Mshikaku/style.go28+++-
M lightsout/Model.go +13 -0
M lightsout/help.md +8 -0
M lightsout/style.go +45 -2
@@ -8,6 +8,49 @@ import (
88 "github.com/FelineStateMachine/puzzletea/game"99 )1010 11+// screenToGrid converts terminal screen coordinates to grid cell coordinates.12+// Returns (col, row, ok) where ok is false if the click landed outside the grid.13+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {14+ gameView := m.View()15+ viewWidth := lipgloss.Width(gameView)16+ viewHeight := lipgloss.Height(gameView)17+18+ if viewWidth > m.termWidth {19+ viewWidth = m.termWidth20+ }21+22+ centerX := max((m.termWidth-viewWidth)/2, 0)23+ centerY := max((m.termHeight-viewHeight)/2, 0)24+25+ // Title bar is always 2 lines (title + blank/subtitle line).26+ titleHeight := strings.Count(game.TitleBarView("Lights Out", m.modeTitle, m.IsSolved()), "\n") + 127+28+ // The grid border adds 1 character on each side.29+ const borderSize = 130+31+ // Grid origin within the centered view.32+ gridInnerWidth := m.width * cellWidth33+ gridOuterWidth := gridInnerWidth + 2*borderSize34+ gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)35+36+ gridX := centerX + gridPadLeft + borderSize37+ gridY := centerY + titleHeight + borderSize38+39+ lx := screenX - gridX40+ ly := screenY - gridY41+ if lx < 0 || ly < 0 {42+ return 0, 0, false43+ }44+45+ col = lx / cellWidth46+ row = ly / cellHeight47+ if col >= m.width || row >= m.height {48+ return 0, 0, false49+ }50+51+ return col, row, true52+}53+1154 var (1255 baseStyle = lipgloss.NewStyle()1356
@@ -80,7 +123,7 @@ func gridView(g [][]bool, c game.Cursor, solved bool) string {
80123 81124 func statusBarView(showFullHelp bool) string {82125 if showFullHelp {83- return game.StatusBarStyle.Render("arrows/wasd: move enter/space: toggle ctrl+n: menu ctrl+r: reset ctrl+h: help")126+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space/click: toggle ctrl+n: menu ctrl+r: reset ctrl+h: help")84127 }85- return game.StatusBarStyle.Render("enter/space: toggle")128+ return game.StatusBarStyle.Render("enter/space/click: toggle")86129 }
M nonogram/help.md +13 -2
M shikaku/Model.go +198 -0
@@ -38,6 +38,24 @@ type Model struct {
3838 keys KeyMap3939 modeTitle string4040 showFullHelp bool41+42+ // Mouse drag support: anchor is the cell where the drag started.43+ mouseDragAnchor *[2]int // nil = not dragging, [col, row] of click origin44+ mousePreview *Rectangle // preview rect computed during drag (for rendering)45+46+ // Screen geometry for mouse hit-testing.47+ termWidth, termHeight int48+49+ // Cached grid origin for mouse hit-testing (recomputed on resize/solve).50+ originX, originY int51+ originValid bool52+53+ // Debug: last mouse event info.54+ lastMouseX, lastMouseY int55+ lastMouseBtn string56+ lastMouseGridCol int57+ lastMouseGridRow int58+ lastMouseHit bool4159 }4260 4361 // New creates a new Shikaku game model.
@@ -56,6 +74,12 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5674 switch msg := msg.(type) {5775 case game.HelpToggleMsg:5876 m.showFullHelp = msg.Show77+78+ case tea.WindowSizeMsg:79+ m.termWidth = msg.Width80+ m.termHeight = msg.Height81+ m.originValid = false82+5983 case tea.KeyPressMsg:6084 if m.puzzle.IsSolved() {6185 m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Width-1, m.puzzle.Height-1)
@@ -64,10 +88,166 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6488 } else {6589 m = m.handleNavMode(msg)6690 }91+92+ case tea.MouseClickMsg:93+ m = m.handleMouseClick(msg)94+95+ case tea.MouseMotionMsg:96+ m = m.handleMouseMotion(msg)97+98+ case tea.MouseReleaseMsg:99+ m = m.handleMouseRelease(msg)67100 }68101 return m, nil69102 }70103 104+func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model {105+ m.lastMouseX, m.lastMouseY = msg.X, msg.Y106+ m.lastMouseBtn = msg.String()107+108+ col, row, ok := m.screenToGrid(msg.X, msg.Y)109+ m.lastMouseGridCol, m.lastMouseGridRow = col, row110+ m.lastMouseHit = ok111+112+ if m.puzzle.IsSolved() || !ok {113+ return m114+ }115+116+ m.cursor.X, m.cursor.Y = col, row117+118+ switch msg.Button {119+ case tea.MouseLeft:120+ // Start a drag from any cell.121+ m.mouseDragAnchor = &[2]int{col, row}122+ r := rectFromCorners(col, row, col, row)123+ m.mousePreview = &r124+ m.selectedClue = nil125+126+ // Sync expansion if the anchor is on a clue (for seamless127+ // keyboard takeover if the user releases without moving).128+ m.syncExpansionFromPreview()129+130+ case tea.MouseRight:131+ // Right-click deletes rectangle at cursor.132+ owner := m.puzzle.CellOwner(col, row)133+ if owner >= 0 {134+ m.puzzle.RemoveRectangle(owner)135+ m.originValid = false136+ }137+ }138+ return m139+}140+141+func (m Model) handleMouseMotion(msg tea.MouseMotionMsg) Model {142+ if m.puzzle.IsSolved() || m.mouseDragAnchor == nil {143+ return m144+ }145+146+ col, row, ok := m.screenToGrid(msg.X, msg.Y)147+ if !ok {148+ return m149+ }150+151+ // Update the preview rectangle spanning anchor to current cell.152+ r := rectFromCorners(m.mouseDragAnchor[0], m.mouseDragAnchor[1], col, row)153+ m.mousePreview = &r154+ m.syncExpansionFromPreview()155+ return m156+}157+158+func (m Model) handleMouseRelease(msg tea.MouseReleaseMsg) Model {159+ if m.mouseDragAnchor == nil {160+ return m161+ }162+163+ preview := m.mousePreview164+ m.mouseDragAnchor = nil165+ m.mousePreview = nil166+167+ if preview == nil {168+ m.selectedClue = nil169+ return m170+ }171+172+ // Find clues inside the drawn rectangle.173+ clues := m.puzzle.CluesInRect(*preview)174+ if len(clues) != 1 {175+ // Not exactly one clue: cancel the preview.176+ m.selectedClue = nil177+ return m178+ }179+180+ clue := clues[0]181+ rect := Rectangle{182+ ClueID: clue.ID,183+ X: preview.X,184+ Y: preview.Y,185+ W: preview.W,186+ H: preview.H,187+ }188+189+ // Place if valid: correct area and no overlap.190+ if rect.Area() == clue.Value && !m.puzzle.Overlaps(rect, clue.ID) {191+ m.puzzle.SetRectangle(rect)192+ m.selectedClue = nil193+ m.originValid = false194+ } else {195+ // Invalid: enter keyboard expansion mode on this clue so the196+ // player can fine-tune.197+ m.selectedClue = &clue.ID198+ m.expansion = expansion{clueID: clue.ID}199+ m.expansion.left = clue.X - preview.X200+ m.expansion.right = preview.X + preview.W - 1 - clue.X201+ m.expansion.up = clue.Y - preview.Y202+ m.expansion.down = preview.Y + preview.H - 1 - clue.Y203+ m.cursor.X, m.cursor.Y = clue.X, clue.Y204+ }205+ return m206+}207+208+// syncExpansionFromPreview updates selectedClue and expansion to match209+// the current mousePreview so that the existing preview rendering works.210+// If the preview contains exactly one clue, that clue is selected;211+// otherwise selectedClue is cleared (the preview is still rendered via212+// mousePreview in gridView).213+func (m *Model) syncExpansionFromPreview() {214+ if m.mousePreview == nil {215+ m.selectedClue = nil216+ return217+ }218+219+ clues := m.puzzle.CluesInRect(*m.mousePreview)220+ if len(clues) == 1 {221+ clue := clues[0]222+ m.selectedClue = &clue.ID223+ m.expansion = expansion{clueID: clue.ID}224+ m.expansion.left = clue.X - m.mousePreview.X225+ m.expansion.right = m.mousePreview.X + m.mousePreview.W - 1 - clue.X226+ m.expansion.up = clue.Y - m.mousePreview.Y227+ m.expansion.down = m.mousePreview.Y + m.mousePreview.H - 1 - clue.Y228+ } else {229+ m.selectedClue = nil230+ }231+}232+233+// rectFromCorners builds a Rectangle spanning two arbitrary grid cells.234+func rectFromCorners(x1, y1, x2, y2 int) Rectangle {235+ minX, maxX := x1, x2236+ if x2 < x1 {237+ minX, maxX = x2, x1238+ }239+ minY, maxY := y1, y2240+ if y2 < y1 {241+ minY, maxY = y2, y1242+ }243+ return Rectangle{244+ X: minX,245+ Y: minY,246+ W: maxX - minX + 1,247+ H: maxY - minY + 1,248+ }249+}250+71251 func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {72252 switch {73253 case key.Matches(msg, m.keys.Select):
@@ -174,6 +354,9 @@ func (m Model) Reset() game.Gamer {
174354 m.puzzle.Rectangles = nil175355 m.cursor = game.Cursor{}176356 m.selectedClue = nil357+ m.mouseDragAnchor = nil358+ m.mousePreview = nil359+ m.originValid = false177360 return m178361 }179362
@@ -188,6 +371,17 @@ func (m Model) GetDebugInfo() string {
188371 selectedStr = fmt.Sprintf("%d", *m.selectedClue)189372 }190373 374+ ox, oy := m.gridOrigin()375+ hitStr := "miss"376+ if m.lastMouseHit {377+ hitStr = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)378+ }379+380+ dragStr := "None"381+ if m.mouseDragAnchor != nil {382+ dragStr = fmt.Sprintf("anchor=(%d, %d)", m.mouseDragAnchor[0], m.mouseDragAnchor[1])383+ }384+191385 s := game.DebugHeader("Shikaku", [][2]string{192386 {"Status", status},193387 {"Grid Size", fmt.Sprintf("%dx%d", m.puzzle.Width, m.puzzle.Height)},
@@ -195,6 +389,10 @@ func (m Model) GetDebugInfo() string {
195389 {"Rectangles", fmt.Sprintf("%d", len(m.puzzle.Rectangles))},196390 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},197391 {"Selected Clue", selectedStr},392+ {"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},393+ {"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},394+ {"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hitStr)},395+ {"Mouse Drag", dragStr},198396 })199397 200398 var rows [][]string
M shikaku/help.md +14 -4
A shikaku/mouse.go +94 -0
@@ -0,0 +1,94 @@
1+package shikaku2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// screenToGrid converts terminal screen coordinates to grid cell coordinates.11+// Returns (col, row, ok) where ok is false if the click landed outside the grid.12+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {13+ ox, oy := m.cachedGridOrigin()14+ lx := screenX - ox15+ ly := screenY - oy16+ if lx < 0 || ly < 0 {17+ return 0, 0, false18+ }19+20+ // Shikaku has no separators: simple division by cell dimensions.21+ col = lx / cellWidth22+ row = ly // cellHeight is 123+24+ if col < 0 || col >= m.puzzle.Width || row < 0 || row >= m.puzzle.Height {25+ return 0, 0, false26+ }27+28+ return col, row, true29+}30+31+// cachedGridOrigin returns the screen position of the top-left corner of32+// cell (0,0), using a cached value when available. The cache is invalidated33+// on terminal resize and solve-state changes.34+func (m *Model) cachedGridOrigin() (x, y int) {35+ if m.originValid {36+ return m.originX, m.originY37+ }38+ x, y = m.gridOrigin()39+ m.originX, m.originY = x, y40+ m.originValid = true41+ return x, y42+}43+44+// gridOrigin computes the screen position of the top-left corner of the45+// first grid cell. Mouse coordinates are terminal-absolute, so we need to46+// account for how the root model centers the game's View() output.47+//48+// The root view does:49+//50+// lipgloss.Place(termWidth, termHeight, Center, Center, gameView)51+//52+// So the centering offset is (termWidth - viewWidth) / 2 for X and53+// (termHeight - viewHeight) / 2 for Y.54+//55+// Within the game view the layout is:56+//57+// title58+// ┌─────────────┐ <- border (1 line top)59+// │ cell grid │ <- border adds 1 char left60+// └─────────────┘61+// info62+// status63+func (m *Model) gridOrigin() (x, y int) {64+ gameView := m.View()65+ viewWidth := lipgloss.Width(gameView)66+ viewHeight := lipgloss.Height(gameView)67+68+ if viewWidth > m.termWidth {69+ viewWidth = m.termWidth70+ }71+72+ // Centering offset applied by the root's lipgloss.Place().73+ centerX := max((m.termWidth-viewWidth)/2, 0)74+ centerY := max((m.termHeight-viewHeight)/2, 0)75+76+ // Measure the title height.77+ solved := m.puzzle.IsSolved()78+ title := game.TitleBarView("Shikaku", m.modeTitle, solved)79+ titleHeight := strings.Count(title, "\n") + 180+81+ // The grid is rendered inside a border. Measure its outer width to82+ // compute horizontal padding from JoinVertical(Center, ...).83+ grid := gridView(*m, solved)84+ gridOuterWidth := lipgloss.Width(grid)85+ gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)86+87+ // Border adds 1 character on the left and 1 line on top.88+ const borderLeft = 189+ const borderTop = 190+91+ x = centerX + gridPadLeft + borderLeft92+ y = centerY + titleHeight + borderTop93+ return x, y94+}
M shikaku/puzzle.go +11 -0
M shikaku/shikaku_test.go +115 -0
@@ -496,6 +496,121 @@ func TestGeneratePuzzle(t *testing.T) {
496496 })497497 }498498 499+// --- screenToGrid (P1) ---500+501+func TestScreenToGrid(t *testing.T) {502+ m := &Model{503+ puzzle: Puzzle{504+ Width: 5,505+ Height: 5,506+ Clues: []Clue{507+ {ID: 0, X: 0, Y: 0, Value: 4},508+ },509+ },510+ keys: DefaultKeyMap,511+ modeTitle: "Test",512+ termWidth: 120,513+ termHeight: 40,514+ }515+516+ ox, oy := m.gridOrigin()517+518+ tests := []struct {519+ name string520+ screenX int521+ screenY int522+ wantCol int523+ wantRow int524+ wantOk bool525+ }{526+ {"origin cell", ox, oy, 0, 0, true},527+ {"cell (1,0)", ox + cellWidth, oy, 1, 0, true},528+ {"cell (0,1)", ox, oy + 1, 0, 1, true},529+ {"cell (2,3)", ox + 2*cellWidth, oy + 3, 2, 3, true},530+ {"cell (4,4) last", ox + 4*cellWidth, oy + 4, 4, 4, true},531+ {"outside left", ox - 1, oy, 0, 0, false},532+ {"outside top", ox, oy - 1, 0, 0, false},533+ {"outside right", ox + 5*cellWidth, oy, 0, 0, false},534+ {"outside bottom", ox, oy + 5, 0, 0, false},535+ {"far outside", 0, 0, 0, 0, false},536+ }537+538+ for _, tt := range tests {539+ t.Run(tt.name, func(t *testing.T) {540+ col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)541+ if ok != tt.wantOk {542+ t.Errorf("ok = %v, want %v", ok, tt.wantOk)543+ }544+ if ok && (col != tt.wantCol || row != tt.wantRow) {545+ t.Errorf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)",546+ tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)547+ }548+ })549+ }550+}551+552+// --- rectFromCorners (P1) ---553+554+func TestRectFromCorners(t *testing.T) {555+ tests := []struct {556+ name string557+ x1, y1, x2, y2 int558+ wantX, wantY, wantW, wantH int559+ }{560+ {"same cell", 3, 3, 3, 3, 3, 3, 1, 1},561+ {"drag right", 1, 2, 4, 2, 1, 2, 4, 1},562+ {"drag left", 4, 2, 1, 2, 1, 2, 4, 1},563+ {"drag down", 2, 1, 2, 5, 2, 1, 1, 5},564+ {"drag up", 2, 5, 2, 1, 2, 1, 1, 5},565+ {"drag down-right", 1, 1, 4, 3, 1, 1, 4, 3},566+ {"drag up-left", 4, 3, 1, 1, 1, 1, 4, 3},567+ }568+569+ for _, tt := range tests {570+ t.Run(tt.name, func(t *testing.T) {571+ r := rectFromCorners(tt.x1, tt.y1, tt.x2, tt.y2)572+ if r.X != tt.wantX || r.Y != tt.wantY || r.W != tt.wantW || r.H != tt.wantH {573+ t.Errorf("rectFromCorners(%d,%d, %d,%d) = (%d,%d) %dx%d, want (%d,%d) %dx%d",574+ tt.x1, tt.y1, tt.x2, tt.y2,575+ r.X, r.Y, r.W, r.H,576+ tt.wantX, tt.wantY, tt.wantW, tt.wantH)577+ }578+ })579+ }580+}581+582+// --- CluesInRect (P1) ---583+584+func TestCluesInRect(t *testing.T) {585+ p := simplePuzzle()586+587+ tests := []struct {588+ name string589+ rect Rectangle590+ wantCount int591+ wantIDs []int592+ }{593+ {"single clue", Rectangle{X: 0, Y: 0, W: 1, H: 1}, 1, []int{0}},594+ {"no clues", Rectangle{X: 1, Y: 1, W: 1, H: 1}, 0, nil},595+ {"two clues", Rectangle{X: 0, Y: 0, W: 4, H: 1}, 2, []int{0, 3}},596+ {"all clues", Rectangle{X: 0, Y: 0, W: 4, H: 4}, 4, []int{0, 1, 2, 3}},597+ }598+599+ for _, tt := range tests {600+ t.Run(tt.name, func(t *testing.T) {601+ clues := p.CluesInRect(tt.rect)602+ if len(clues) != tt.wantCount {603+ t.Fatalf("CluesInRect returned %d clues, want %d", len(clues), tt.wantCount)604+ }605+ for i, c := range clues {606+ if c.ID != tt.wantIDs[i] {607+ t.Errorf("clue[%d].ID = %d, want %d", i, c.ID, tt.wantIDs[i])608+ }609+ }610+ })611+ }612+}613+499614 // --- Mode Spawn ---500615 501616 func TestModeSpawn(t *testing.T) {
M shikaku/style.go +20 -8
@@ -63,10 +63,19 @@ var (
6363 )6464 6565 func gridView(m Model, solved bool) string {66- // Build preview rectangle if in expansion mode.66+ // Build preview rectangle for visual feedback.6767 var preview *Rectangle6868 var previewClue *Clue69- if m.selectedClue != nil {69+70+ if m.mousePreview != nil {71+ // Mouse drag in progress: use the free-form preview rect.72+ preview = m.mousePreview73+ clues := m.puzzle.CluesInRect(*preview)74+ if len(clues) == 1 {75+ previewClue = clues[0]76+ }77+ } else if m.selectedClue != nil {78+ // Keyboard expansion mode.7079 previewClue = m.puzzle.FindClueByID(*m.selectedClue)7180 if previewClue != nil {7281 r := m.expansion.rect(previewClue)
@@ -122,8 +131,11 @@ func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *C
122131 123132 // Preview overlay.124133 inPreview := preview != nil && preview.Contains(x, y)125- if inPreview && previewClue != nil {126- if preview.Area() == previewClue.Value && !m.puzzle.Overlaps(*preview, previewClue.ID) {134+ if inPreview {135+ good := previewClue != nil &&136+ preview.Area() == previewClue.Value &&137+ !m.puzzle.Overlaps(*preview, previewClue.ID)138+ if good {127139 s = s.Background(previewGoodBg).Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("255")})128140 } else {129141 s = s.Background(previewBadBg).Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("255")})
@@ -177,12 +189,12 @@ func infoView(p *Puzzle) string {
177189 func statusBarView(selected, showFullHelp bool) string {178190 if selected {179191 if showFullHelp {180- return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")192+ return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel bkspc: delete mouse: drag rect ctrl+n: menu ctrl+r: reset ctrl+h: help")181193 }182- return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel")194+ return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel mouse: drag")183195 }184196 if showFullHelp {185- return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select clue bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")197+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select clue bkspc: delete mouse: click clue & drag ctrl+n: menu ctrl+r: reset ctrl+h: help")186198 }187- return game.StatusBarStyle.Render("enter/space: select clue bkspc: delete")199+ return game.StatusBarStyle.Render("enter/space: select clue bkspc: delete mouse: click & drag")188200 }