]> git.cworth.org Git - lmno.games/commitdiff
Fix Anagrams: draggable center tiles, keyboard input, countdown animation, claim...
authorCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 05:22:37 +0000 (00:22 -0500)
committerCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 12:45:57 +0000 (08:45 -0400)
1. Center tiles can be dragged to rearrange (client-side only,
   mouse and touch) to help visualize possible words.
2. Keyboard input during claiming: type letters to grab from
   center, Backspace to return last, Enter to submit, Escape
   to cancel.
3. Countdown shows 3, 2, 1 on the tile with opacity fade
   animation each second.
4. Fix cancelled claims not returning tiles to center (let
   server SSE events handle state cleanup).
5. Fix UI staying in claim mode after successful word submission
   (server-side fix: broadcast claim-end after acceptance).
Also remove game_steal references (deferred for later).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
anagrams/anagrams.css
anagrams/anagrams.jsx

index c194c3c854f09afe3bc561018814cb0af2550230..4159125b7b9b14ac307bde612dc14895d7a4db39 100644 (file)
 .center-pool .tile.revealing {
   cursor: default;
   background: #c9a96e;
-  color: transparent;
-}
-
-.center-pool .tile .countdown {
-  position: absolute;
   color: white;
-  font-size: 18px;
-  font-weight: bold;
+  font-size: 24px;
 }
 
 /* Claim rack (shown when claiming) */
index 8969fc1a22813f20f594823b1d3d4589ab73ea52..caaf382ed5a877a3ea33dbdc89a51cf3b1f99156 100644 (file)
@@ -101,7 +101,7 @@ class Game extends React.Component {
       joined: false,
       /* Center pool letters */
       center: [],
-      revealing: {},  /* letter_id -> countdown seconds remaining */
+      revealing: {},  /* letter_id -> { seconds, opacity } */
       /* Player words: { session_id: { name, words: [...] } } */
       player_words: {},
       /* Scores: { session_id: { name, score, words } } */
@@ -127,13 +127,23 @@ class Game extends React.Component {
       /* Game over */
       game_over: false,
       final_scores: null,
-      game_steal: null,
       /* Tile positions in center (for random placement) */
-      tile_positions: {}
+      tile_positions: {},
+      /* Center tile dragging (client-side rearrangement) */
+      dragging_center_tile: null
     };
     this._claim_interval = null;
   }
 
+  componentDidMount() {
+    this._onKeyDown = (e) => this.on_key_down(e);
+    document.addEventListener("keydown", this._onKeyDown);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener("keydown", this._onKeyDown);
+  }
+
   /*****************************************************
    * SSE event handlers                                *
    *****************************************************/
@@ -173,7 +183,6 @@ class Game extends React.Component {
   receive_letter_reveal(data) {
     const { tile, remaining, countdown_ms } = data;
     const center = [...this.state.center];
-    /* Tile was already added server-side; update if present, else add. */
     if (!center.find(t => t.id === tile.id)) {
       center.push(tile);
     }
@@ -181,8 +190,9 @@ class Game extends React.Component {
     if (!positions[tile.id]) {
       positions[tile.id] = this._random_position();
     }
+    const seconds = Math.ceil(countdown_ms / 1000);
     const revealing = { ...this.state.revealing };
-    revealing[tile.id] = Math.ceil(countdown_ms / 1000);
+    revealing[tile.id] = { seconds, opacity: 1 };
 
     this.setState({
       center,
@@ -192,20 +202,37 @@ class Game extends React.Component {
       letter_request_votes: 0
     });
 
-    /* Tick down the countdown. */
-    const tick = () => {
-      this.setState(prev => {
-        const r = { ...prev.revealing };
-        if (r[tile.id] !== undefined) {
-          r[tile.id]--;
-          if (r[tile.id] <= 0) delete r[tile.id];
+    /* Animate: each second, show the number with a fade from full
+     * opacity to transparent, then switch to the next number. */
+    const animate = () => {
+      const start = Date.now();
+      const step = () => {
+        this.setState(prev => {
+          const r = { ...prev.revealing };
+          const entry = r[tile.id];
+          if (!entry) return { revealing: r };
+
+          const elapsed = Date.now() - start;
+
+          if (elapsed >= entry.seconds * 1000) {
+            /* Countdown complete — reveal the letter. */
+            delete r[tile.id];
+            return { revealing: r };
+          }
+
+          const current_second = entry.seconds - Math.floor(elapsed / 1000);
+          const frac = (elapsed % 1000) / 1000;
+          r[tile.id] = { seconds: current_second, opacity: 1 - frac };
+          return { revealing: r };
+        });
+
+        if (this.state.revealing[tile.id]) {
+          requestAnimationFrame(step);
         }
-        return { revealing: r };
-      });
+      };
+      requestAnimationFrame(step);
     };
-    for (let i = 1; i <= Math.ceil(countdown_ms / 1000); i++) {
-      setTimeout(tick, i * 1000);
-    }
+    animate();
   }
 
   receive_bag_count(data) {
@@ -323,8 +350,7 @@ class Game extends React.Component {
   receive_game_over(data) {
     this.setState({
       game_over: true,
-      final_scores: data.scores,
-      game_steal: data.game_steal
+      final_scores: data.scores
     });
   }
 
@@ -438,13 +464,8 @@ class Game extends React.Component {
 
   async cancel_claim() {
     await fetch_post_json("cancel-claim");
-    this.setState({
-      claiming: false,
-      claim_active: false,
-      claim_rack: [],
-      claimed_words: [],
-      claim_error: null
-    });
+    /* State cleanup handled by the claim-end SSE event and
+     * letter-returned / word-returned events from the server. */
   }
 
   async vote(accept) {
@@ -497,6 +518,144 @@ class Game extends React.Component {
     };
   }
 
+  /*****************************************************
+   * Keyboard input during claiming                    *
+   *****************************************************/
+
+  on_key_down(e) {
+    if (!this.state.claim_active) return;
+
+    /* Ignore if typing in an input field. */
+    if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
+
+    const key = e.key.toUpperCase();
+
+    if (key === "BACKSPACE" || key === "DELETE") {
+      /* Return the last letter from the rack. */
+      e.preventDefault();
+      const rack = this.state.claim_rack;
+      if (rack.length > 0) {
+        this.return_letter(rack[rack.length - 1]);
+      }
+      return;
+    }
+
+    if (key === "ENTER") {
+      e.preventDefault();
+      if (this.state.claim_rack.length >= 4) {
+        this.submit_word();
+      }
+      return;
+    }
+
+    if (key === "ESCAPE") {
+      e.preventDefault();
+      this.cancel_claim();
+      return;
+    }
+
+    if (!/^[A-Z]$/.test(key)) return;
+    e.preventDefault();
+
+    /* Find a matching letter in the center and claim it. */
+    const tile = this.state.center.find(
+      t => t.letter === key && !this.state.revealing[t.id]
+    );
+    if (tile) {
+      this.take_letter(tile);
+    }
+  }
+
+  /*****************************************************
+   * Center tile dragging (client-side rearrangement)  *
+   *****************************************************/
+
+  on_center_mouse_down(e, tile) {
+    /* Only allow rearranging when not in claim mode. */
+    if (this.state.claim_active) return;
+    if (e.button !== 0) return;
+
+    const pool = e.target.closest(".center-pool");
+    if (!pool) return;
+
+    const rect = pool.getBoundingClientRect();
+    this._drag_offset = {
+      x: e.clientX - (this.state.tile_positions[tile.id].x / 100 * rect.width),
+      y: e.clientY - (this.state.tile_positions[tile.id].y / 100 * rect.height)
+    };
+    this._drag_pool_rect = rect;
+    this.setState({ dragging_center_tile: tile.id });
+
+    const onMove = (me) => {
+      const x = ((me.clientX - this._drag_offset.x) / rect.width) * 100;
+      const y = ((me.clientY - this._drag_offset.y) / rect.height) * 100;
+      this.setState(prev => ({
+        tile_positions: {
+          ...prev.tile_positions,
+          [tile.id]: {
+            x: Math.max(0, Math.min(90, x)),
+            y: Math.max(0, Math.min(85, y))
+          }
+        }
+      }));
+    };
+
+    const onUp = () => {
+      document.removeEventListener("mousemove", onMove);
+      document.removeEventListener("mouseup", onUp);
+      this.setState({ dragging_center_tile: null });
+    };
+
+    document.addEventListener("mousemove", onMove);
+    document.addEventListener("mouseup", onUp);
+    e.preventDefault();
+  }
+
+  on_center_touch_start(e, tile) {
+    if (this.state.claim_active) return;
+    if (e.touches.length !== 1) return;
+
+    const pool = e.target.closest(".center-pool");
+    if (!pool) return;
+
+    const touch = e.touches[0];
+    const rect = pool.getBoundingClientRect();
+    this._drag_offset = {
+      x: touch.clientX - (this.state.tile_positions[tile.id].x / 100 * rect.width),
+      y: touch.clientY - (this.state.tile_positions[tile.id].y / 100 * rect.height)
+    };
+    this._drag_pool_rect = rect;
+    this._touch_moved = false;
+    this.setState({ dragging_center_tile: tile.id });
+
+    e.preventDefault();
+  }
+
+  on_center_touch_move(e) {
+    if (this.state.dragging_center_tile === null) return;
+    const touch = e.touches[0];
+    const rect = this._drag_pool_rect;
+    this._touch_moved = true;
+
+    const x = ((touch.clientX - this._drag_offset.x) / rect.width) * 100;
+    const y = ((touch.clientY - this._drag_offset.y) / rect.height) * 100;
+    this.setState(prev => ({
+      tile_positions: {
+        ...prev.tile_positions,
+        [this.state.dragging_center_tile]: {
+          x: Math.max(0, Math.min(90, x)),
+          y: Math.max(0, Math.min(85, y))
+        }
+      }
+    }));
+    e.preventDefault();
+  }
+
+  on_center_touch_end(e) {
+    if (this.state.dragging_center_tile === null) return;
+    this.setState({ dragging_center_tile: null });
+  }
+
   /*****************************************************
    * Render                                            *
    *****************************************************/
@@ -582,10 +741,14 @@ class Game extends React.Component {
   }
 
   render_center_pool() {
-    const { center, tile_positions, revealing, claim_active } = this.state;
+    const { center, tile_positions, revealing, claim_active,
+            dragging_center_tile } = this.state;
 
     return (
-      <div key="center" className="center-pool">
+      <div key="center" className="center-pool"
+           onTouchMove={(e) => this.on_center_touch_move(e)}
+           onTouchEnd={(e) => this.on_center_touch_end(e)}
+           onTouchCancel={(e) => this.on_center_touch_end(e)}>
         {center.length === 0 ? (
           <div className="status">
             No letters yet. Press the bag to request one.
@@ -593,21 +756,38 @@ class Game extends React.Component {
         ) : null}
         {center.map(tile => {
           const pos = tile_positions[tile.id] || { x: 50, y: 50 };
-          const is_revealing = revealing[tile.id] !== undefined;
+          const rev = revealing[tile.id];
+          const is_revealing = rev !== undefined;
+          const is_dragging = dragging_center_tile === tile.id;
           const style = {
             left: pos.x + "%",
-            top: pos.y + "%"
+            top: pos.y + "%",
+            zIndex: is_dragging ? 10 : 1,
+            cursor: claim_active && !is_revealing ? "pointer" :
+                    !claim_active && !is_revealing ? "grab" : "default"
           };
 
+          let letter, className = "";
+          if (is_revealing) {
+            letter = rev.seconds;
+            className = "revealing";
+            style.opacity = rev.opacity;
+          } else {
+            letter = tile.letter;
+          }
+
           return (
-            <Tile key={tile.id}
-                  letter={is_revealing ? revealing[tile.id] : tile.letter}
-                  className={is_revealing ? "revealing" : ""}
-                  style={style}
-                  draggable={claim_active && !is_revealing}
-                  onClick={claim_active && !is_revealing
-                    ? () => this.take_letter(tile) : null}
-            />
+            <div key={tile.id}
+                 className={"tile" + (className ? " " + className : "")}
+                 style={style}
+                 onMouseDown={!claim_active && !is_revealing
+                   ? (e) => this.on_center_mouse_down(e, tile) : null}
+                 onTouchStart={!claim_active && !is_revealing
+                   ? (e) => this.on_center_touch_start(e, tile) : null}
+                 onClick={claim_active && !is_revealing
+                   ? () => this.take_letter(tile) : null}>
+              {letter}
+            </div>
           );
         })}
       </div>
@@ -722,7 +902,7 @@ class Game extends React.Component {
   }
 
   render_game_over() {
-    const { final_scores, game_steal, game_info } = this.state;
+    const { final_scores, game_info } = this.state;
 
     const sorted = Object.values(final_scores)
       .sort((a, b) => b.score - a.score);
@@ -755,13 +935,7 @@ class Game extends React.Component {
             ))}
           </div>
         ))}
-      </div>,
-      game_steal ? (
-        <div key="steal" className="claim-notification">
-          The game steals {game_steal.words.map(w => w.word).join(" + ")} to
-          make <strong>{game_steal.result}</strong>!
-        </div>
-      ) : null
+      </div>
     ];
   }
 }