]> git.cworth.org Git - lmno.games/commitdiff
letterrip: Display scores at the end of the game
authorCarl Worth <cworth@cworth.org>
Sat, 7 Mar 2026 04:08:17 +0000 (23:08 -0500)
committerCarl Worth <cworth@cworth.org>
Sat, 7 Mar 2026 13:19:16 +0000 (08:19 -0500)
The server now computes a score for each player and sends them out,
so display the scores and the board for each player.

letterrip/letterrip.css
letterrip/letterrip.jsx

index 653df6356612e052ca64a60ea1e865edbd38285c..6fb309a3b9578528a61faa0bc3450096bd9cbdc5 100644 (file)
   font-style: italic;
   color: #555;
 }
+
+/* Scoreboard */
+.scoreboard h2 {
+  margin-bottom: 0.5em;
+}
+
+.player-result {
+  margin-bottom: 2em;
+}
+
+.player-score-line {
+  display: flex;
+  align-items: baseline;
+  gap: 0.5em;
+  margin-bottom: 0.5em;
+  font-size: 1.1em;
+}
+
+.player-score-line .rank {
+  font-weight: bold;
+  color: #666;
+}
+
+.player-score-line .player-name {
+  font-weight: bold;
+}
+
+.player-score-line .player-score {
+  margin-left: auto;
+  font-weight: bold;
+  color: #27ae60;
+}
+
+.player-score-line .player-score.negative {
+  color: #c0392b;
+}
+
+/* Scored grid (read-only, compact) */
+.scored-grid {
+  display: inline-grid;
+  gap: 0;
+  --scored-cell-size: 36px;
+}
+
+.scored-cell {
+  width: var(--scored-cell-size);
+  height: var(--scored-cell-size);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.scored-grid .tile {
+  width: 32px;
+  height: 32px;
+  font-size: 16px;
+  cursor: default;
+}
+
+.scored-grid-empty {
+  color: #999;
+  font-style: italic;
+}
+
+/* Unscored tiles (in results display) */
+.tile.unscored {
+  opacity: 0.4;
+}
+
+/* Unplaced tiles shown below scored grid */
+.result-rack {
+  display: flex;
+  gap: 4px;
+  margin-top: 0.5em;
+}
+
+.result-rack .tile {
+  width: 32px;
+  height: 32px;
+  font-size: 16px;
+  cursor: default;
+}
index ee76f40da6341dc58e4fefb35f712f511ef30d24..bc30d2574726ab3f0e925918c1f5135cea487bce 100644 (file)
@@ -305,6 +305,86 @@ function BlankTileModal(props) {
   ];
 }
 
+function ScoredGrid(props) {
+  const keys = Object.keys(props.grid);
+  if (keys.length === 0) {
+    return <div className="scored-grid-empty">No tiles placed</div>;
+  }
+
+  let minR = Infinity, maxR = -Infinity, minC = Infinity, maxC = -Infinity;
+  for (const key of keys) {
+    const [r, c] = key.split(",").map(Number);
+    if (r < minR) minR = r;
+    if (r > maxR) maxR = r;
+    if (c < minC) minC = c;
+    if (c > maxC) maxC = c;
+  }
+
+  const cells = [];
+  for (let r = minR; r <= maxR; r++) {
+    for (let c = minC; c <= maxC; c++) {
+      const key = r + "," + c;
+      const cell = props.grid[key];
+      if (cell) {
+        let className = "tile";
+        if (cell.isBlank) className += " blank";
+        if (!cell.scored) className += " unscored";
+        cells.push(
+          <div key={key} className="scored-cell">
+            <div className={className}>{cell.letter}</div>
+          </div>
+        );
+      } else {
+        cells.push(<div key={key} className="scored-cell" />);
+      }
+    }
+  }
+
+  const cols = maxC - minC + 1;
+  return (
+    <div className="scored-grid"
+         style={{ gridTemplateColumns: `repeat(${cols}, var(--scored-cell-size))` }}>
+      {cells}
+    </div>
+  );
+}
+
+function ScoreBoard(props) {
+  const sorted = [...props.results].sort((a, b) => b.score - a.score);
+  return (
+    <div className="scoreboard">
+      <h2>Final Scores</h2>
+      {sorted.map((result, i) => (
+        <div key={result.name} className="player-result">
+          <div className="player-score-line">
+            <span className="rank">#{i + 1}</span>
+            <span className="player-name">{result.name}</span>
+            <span className={"player-score" +
+              (result.score < 0 ? " negative" : "")}>
+              {result.score} {result.score === 1 || result.score === -1
+                ? "point" : "points"}
+            </span>
+          </div>
+          <ScoredGrid grid={result.scored_grid} />
+          {result.rack.length > 0 ? (
+            <div className="result-rack">
+              {result.rack.map((letter, j) => {
+                const is_blank = (letter === "_");
+                return (
+                  <div key={j}
+                       className={"tile unscored" + (is_blank ? " blank" : "")}>
+                    {is_blank ? "\u00A0" : letter}
+                  </div>
+                );
+              })}
+            </div>
+          ) : null}
+        </div>
+      ))}
+    </div>
+  );
+}
+
 function Tile(props) {
   const { letter, isBlank, invalid, unconnected, selected,
           onDragStart, onDragEnd, onClick, onTouchStart } = props;
@@ -341,7 +421,7 @@ class Game extends React.Component {
       stuck_players: [],
       bag_remaining: null,
       game_over: false,
-      winner: null,
+      results: null,
       blank_pending: null,
       drag_source: null,
       drag_over_cell: null,
@@ -485,7 +565,7 @@ class Game extends React.Component {
   }
 
   receive_game_over(data) {
-    this.setState({ game_over: true, winner: data.winner });
+    this.setState({ game_over: true, results: data.results || null });
   }
 
   /*****************************************************
@@ -908,6 +988,15 @@ class Game extends React.Component {
 
   render() {
     const state = this.state;
+
+    /* Show scoreboard when results are in. */
+    if (state.results) {
+      return [
+        <GameInfo key="gi" id={state.game_info.id} url={state.game_info.url} />,
+        <ScoreBoard key="sb" results={state.results} />
+      ];
+    }
+
     const analysis = analyze_grid(state.grid);
     const invalid_cells = get_invalid_cells(state.grid, analysis);
     const unconnected_cells = get_unconnected_cells(state.grid, analysis);
@@ -925,7 +1014,7 @@ class Game extends React.Component {
 
       state.game_over ? (
         <div key="go" className="game-over-banner">
-          {state.winner} wins! Game over.
+          Game over! Calculating scores...
         </div>
       ) : null,