ea83374ddb1d7428cd8535f116d7f9bd0f29fa72

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add htmx game loop: guess, result, next, and restart endpoints

Wire the web UI to the full game flow using htmx partial swaps. The
'game' template renders the HUD plus the current state (playing/result/
gameover), and every htmx response re-renders it so score and round stay
in sync.

- Game gains submitGuess (reuses fuzzyScore/scoreFor from matching.go),
  nextRound, and restart methods
- New endpoints: POST /guess, GET /next, POST /restart
- Templates split into game/playing/result/gameover partials; the playing
  state autoplays the /audio clip and focuses the title field via a small
  inline script (no new JS deps)
- Full dark-theme CSS for form, result breakdown, field status pills, and
  the game-over screen

Diff

This diff is truncated to protect this page.

  1diff --git a/server.go b/server.go
  2index 174a7d0810896fa2b46cf91ed261f46ee577d675..33023444c5d91f53b7284dcaaa5ee37645a97533 100644
  3--- a/server.go
  4+++ b/server.go
  5@@ -2,6 +2,7 @@ package main
  6 
  7 import (
  8 	"embed"
  9+	"fmt"
 10 	"html/template"
 11 	"io/fs"
 12 	"net/http"
 13@@ -85,6 +86,80 @@ func (g *Game) nextSong() Song {
 14 	return g.catalog[idx]
 15 }
 16 
 17+// submitGuess scores the player's guess against the current answer and stores
 18+// the result for rendering.
 19+func (g *Game) submitGuess(title, album, artist string) {
 20+	guesses := []string{title, album, artist}
 21+	points := 0
 22+	var parts []string
 23+	fieldCorrect := make([]bool, len(fields))
 24+	fieldPartial := make([]bool, len(fields))
 25+	for i, f := range fields {
 26+		v := guesses[i]
 27+		if v != "" {
 28+			sim := fuzzyScore(v, f.value(g.answer))
 29+			points += scoreFor(sim, f.weight)
 30+			switch {
 31+			case sim >= matchThreshold:
 32+				fieldCorrect[i] = true
 33+				parts = append(parts, f.label)
 34+			case sim >= lowerThreshold:
 35+				fieldPartial[i] = true
 36+			}
 37+		}
 38+	}
 39+	if g.hinted {
 40+		points /= 2
 41+	}
 42+	g.score += points
 43+	g.played++
 44+	g.result = &RoundResult{
 45+		Points:       points,
 46+		Parts:        parts,
 47+		Guesses:      guesses,
 48+		FieldCorrect: fieldCorrect,
 49+		FieldPartial: fieldPartial,
 50+		ResultMsg:    resultMessage(points, parts),
 51+		Hinted:       g.hinted,
 52+	}
 53+	if g.played >= g.rounds {
 54+		g.done = true
 55+	}
 56+}
 57+
 58+// resultMessage builds the human-readable summary line for a scored guess.
 59+func resultMessage(points int, parts []string) string {
 60+	switch {
 61+	case len(parts) == 3:
 62+		return fmt.Sprintf("Correct! +%d %s", points, pointWord(points))
 63+	case len(parts) > 0:
 64+		return fmt.Sprintf("%s correct! +%d %s", strings.Join(parts, ", "), points, pointWord(points))
 65+	case points > 0:
 66+		return fmt.Sprintf("+%d %s", points, pointWord(points))
 67+	default:
 68+		return "Wrong."
 69+	}
 70+}
 71+
 72+// nextRound advances to the next song (generating its audio clip), or is a
 73+// no-op when the game is finished.
 74+func (g *Game) nextRound() {
 75+	if g.done {
 76+		return
 77+	}
 78+	g.beginRound()
 79+}
 80+
 81+// restart begins a fresh game with a new song order and zero score.
 82+func (g *Game) restart() {
 83+	g.order = shuffled(len(g.catalog))
 84+	g.pos = 0
 85+	g.played = 0
 86+	g.score = 0
 87+	g.done = false
 88+	g.beginRound()
 89+}
 90+
 91 // viewData is the render model passed to the templates.
 92 type viewData struct {
 93 	Score       int
 94@@ -98,10 +173,14 @@ type viewData struct {
 95 }
 96 
 97 func (g *Game) viewData() viewData {
 98+	round := g.played + 1
 99+	if round > g.rounds {
100+		round = g.rounds
101+	}
102 	return viewData{
103 		Score:       g.score,
104 		MaxScore:    g.rounds * pointsPerRound,
105diff --git a/web/static/style.css b/web/static/style.css
106index bb7fa35426dc8a3540f9b9fedbd2498a879bf8fb..c6b52ecb7fb2e9fae26767594f6d8162e0d6a626 100644
107--- a/web/static/style.css
108+++ b/web/static/style.css
109@@ -1,13 +1,14 @@
110 :root {
111-  --bg: #2e3440;
112-  --bg-2: #3b4252;
113-  --fg: #d8dee9;
114-  --dim: #7b889a;
115-  --accent: #a89984;
116-  --green: #a3be5c;
117-  --red: #bf616a;
118-  --yellow: #e5c07b;
119-  --orange: #d08a52;
120+  --bg: #23262e;
121+  --bg-2: #2c3038;
122+  --card: #2a2e37;
123+  --fg: #e6e9ef;
124+  --dim: #8b93a1;
125+  --accent: #e0a458;
126+  --green: #7fbf6a;
127+  --red: #d97b74;
128+  --yellow: #e0c058;
129+  --line: #3a3f4a;
130 }
131 
132 * { box-sizing: border-box; }
133@@ -21,13 +22,18 @@ body {
134   align-items: center;
135   justify-content: center;
136   padding: 24px;
137-  background: radial-gradient(120% 120% at 50% 0%, var(--bg-2), var(--bg) 60%);
138+  background: radial-gradient(120% 130% at 50% -10%, var(--bg-2), var(--bg) 62%);
139   color: var(--fg);
140-  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
141+  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
142 }
143 
144 .game {
145-  width: min(92vw, 560px);
146+  width: min(94vw, 540px);
147+  background: linear-gradient(180deg, var(--card), #242832);
148+  border: 1px solid var(--line);
149+  border-radius: 16px;
150+  padding: 26px 28px 30px;
151+  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
152 }
153 
154 .hud {
155@@ -39,18 +45,126 @@ body {
156 
157 h1 {
158   margin: 0;
159-  font-size: 1.9rem;
160-  letter-spacing: 0.5px;
161+  font-size: 1.7rem;
162+  letter-spacing: 0.4px;
163   color: var(--accent);
164 }
165 
166 .score {
167-  font-size: 1.05rem;
168+  font-size: 1rem;
169   color: var(--dim);
170   white-space: nowrap;
171+  font-variant-numeric: tabular-nums;
172 }
173 
174 .round {
175   color: var(--dim);
176-  margin-top: 4px;
177+  margin: 2px 0 18px;
178+  font-size: 0.9rem;
179 }
180+
181+/* Guess form */
182+.guess { display: flex; flex-direction: column; gap: 14px; }
183+
184+.field { display: flex; flex-direction: column; gap: 6px; }
185+
186+.field label {
187+  font-size: 0.78rem;
188+  text-transform: uppercase;
189+  letter-spacing: 0.8px;
190+  color: var(--dim);
191+}
192+
193+.field input {
194+  background: #20242c;
195+  border: 1px solid var(--line);
196+  border-radius: 10px;
197+  color: var(--fg);
198+  font-size: 1rem;
199+  padding: 11px 13px;
200+  outline: none;
201+  transition: border-color 0.15s, box-shadow 0.15s;
202+}
203+
204+.field input:focus {
205+  border-color: var(--accent);
206+  box-shadow: 0 0 0 3px rgba(224, 164, 88, 0.18);
207+}
208+
209diff --git a/web/templates/home.html b/web/templates/game.html
210rename from web/templates/home.html
211rename to web/templates/game.html
212index 9fede01a1bb59be3a6fbb94f9751094ab11e5ee1..642bb80a3f80a926a77787fc9d5350259319d107 100644
213--- a/web/templates/home.html
214+++ b/web/templates/game.html
215@@ -1,7 +1,9 @@
216-{{define "content"}}
217+{{define "game"}}
218 <header class="hud">
219   <h1>Muse Guessr</h1>
220   <div class="score">Score: {{.Score}}/{{.MaxScore}}</div>
221 </header>
222 <p class="round">Round {{.Round}} of {{.TotalRounds}}</p>
223+
224+{{if .Done}}{{template "gameover" .}}{{else if .Result}}{{template "result" .}}{{else}}{{template "playing" .}}{{end}}
225 {{end}}
226diff --git a/web/templates/gameover.html b/web/templates/gameover.html
227new file mode 100644
228index 0000000000000000000000000000000000000000..202814264b79f4591523c586df1cbb385b76fc2c
229--- /dev/null
230+++ b/web/templates/gameover.html
231@@ -0,0 +1,7 @@
232+{{define "gameover"}}
233+<div class="gameover">
234+  <div class="final-score">{{.Score}}</div>
235+  <p class="final-sub">out of {{.MaxScore}} points</p>
236+  <button hx-post="/restart" hx-target="#game" hx-swap="innerHTML">Play again</button>
237+</div>
238+{{end}}
239diff --git a/web/templates/layout.html b/web/templates/layout.html
240index abe4071c6ee5e76a9082c1589459ba0258c6fb88..55934db19db2ad51cb3e3baaed10bf99c40cc616 100644
241--- a/web/templates/layout.html
242+++ b/web/templates/layout.html
243@@ -9,8 +9,8 @@
244   <script src="/static/htmx.min.js"></script>
245 </head>
246 <body>
247-  <main class="game">
248-    {{template "content" .}}
249+  <main class="game" id="game">
250+    {{template "game" .}}
251   </main>
252 </body>
253 </html>
254diff --git a/web/templates/playing.html b/web/templates/playing.html
255new file mode 100644
256index 0000000000000000000000000000000000000000..9466a0807baeb2cd4e6e26280731902c4ce118e5
257--- /dev/null
258+++ b/web/templates/playing.html
259@@ -0,0 +1,26 @@
260+{{define "playing"}}
261+<audio id="clip" src="/audio" autoplay></audio>
262+<script>
263+  (function () {
264+    var a = document.getElementById('clip');
265+    if (a) { a.play().catch(function () {}); }
266+    var t = document.getElementById('g-title');
267+    if (t) { t.focus(); }
268+  })();
269+</script>
270+<form class="guess" hx-post="/guess" hx-target="#game" hx-swap="innerHTML">
271+  <div class="field">
272+    <label for="g-title">Title</label>
273+    <input id="g-title" name="title" type="text" autocomplete="off">
274+  </div>
275+  <div class="field">
276+    <label for="g-album">Album</label>
277+    <input id="g-album" name="album" type="text" autocomplete="off">
278+  </div>
279+  <div class="field">
280+    <label for="g-artist">Artist</label>
281+    <input id="g-artist" name="artist" type="text" autocomplete="off">
282+  </div>
283+  <button type="submit">Submit guess</button>
284+</form>
285+{{end}}
286diff --git a/web/templates/result.html b/web/templates/result.html
287new file mode 100644
288index 0000000000000000000000000000000000000000..4bad4e8fd8d1d9c87fded25bb81fa2a5a376af18
289--- /dev/null
290+++ b/web/templates/result.html
291@@ -0,0 +1,24 @@
292+{{define "result"}}
293+<div class="result">
294+  <div class="points {{if eq .Result.Points 0}}zero{{end}}">+{{.Result.Points}}</div>
295+  <ul class="parts">
296+    {{range .Result.Parts}}<li>{{.}}</li>
297+    {{end}}
298+  </ul>
299+  <div class="fields">
300+    <span class="f {{if index .Result.FieldCorrect 0}}ok{{else if index .Result.FieldPartial 0}}part{{else}}miss{{end}}">Title</span>
301+    <span class="f {{if index .Result.FieldCorrect 1}}ok{{else if index .Result.FieldPartial 1}}part{{else}}miss{{end}}">Album</span>
302+    <span class="f {{if index .Result.FieldCorrect 2}}ok{{else if index .Result.FieldPartial 2}}part{{else}}miss{{end}}">Artist</span>
303+  </div>
304+  <div class="answer">
305+    <strong>{{.Answer.Title}}</strong> &mdash; {{.Answer.Artist}}
306+    {{if .Answer.Album}}<span class="album">{{.Answer.Album}}</span>{{end}}
307+  </div>
308+  {{if .Result.Hinted}}<div class="hinted">Hint used</div>{{end}}
309+</div>
310+{{if .Done}}
311+<button hx-post="/restart" hx-target="#game" hx-swap="innerHTML">Play again</button>
312+{{else}}
313+<button hx-get="/next" hx-target="#game" hx-swap="innerHTML">Next song</button>
314+{{end}}
315+{{end}}