]> git.cworth.org Git - lmno.games/commitdiff
anagrams: Fix claim interface to work for stealing a word
authorCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 16:18:08 +0000 (12:18 -0400)
committerCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 16:18:08 +0000 (12:18 -0400)
Typing letters in the claim area now draws from stolen words first,
(if possible), and from the center area otherwise. Each letter used
from a stolen word is marked in accent-color-muted to show it has
been used. The backspace key or a little "X" badge can be used to
return letters from the rack back to where they came from.

anagrams/anagrams.css
anagrams/anagrams.jsx

index 5ab18cc1ce92f117a931e80dcc5659913625cce7..620465bc68c3a40ea42dfc016375e36acdb012af 100644 (file)
   cursor: pointer;
 }
 
+/* Clickable word letters in source row */
+.claimed-word .tile {
+  cursor: pointer;
+}
+
+.claimed-word .tile.used {
+  background: var(--accent-color-muted);
+  color: #aaa;
+  cursor: default;
+}
+
+/* Rack tile with remove badge */
+.rack-tile-wrapper {
+  position: relative;
+  display: inline-block;
+}
+
+.rack-tile-wrapper .remove-tile-btn {
+  position: absolute;
+  top: -6px;
+  right: -6px;
+  width: 16px;
+  height: 16px;
+  line-height: 16px;
+  text-align: center;
+  font-size: 10px;
+  background: #888;
+  color: white;
+  border-radius: 50%;
+  cursor: pointer;
+  z-index: 1;
+}
+
 .separator {
   font-size: 18px;
   font-weight: bold;
index 8fac388ba0954f13abe836c2e4198329133b398f..712e21c962fd17422e1e5ce37a32ad3899c946f1 100644 (file)
@@ -113,8 +113,9 @@ class Game extends React.Component {
       claiming: false,
       claim_active: false,      /* true if I'm the active claimer */
       claim_player: null,       /* name of the current claimer */
-      claim_rack: [],           /* tiles I've claimed (in my order) */
+      claim_rack: [],           /* { letter, source, tile?, word_id?, idx? } */
       claimed_words: [],        /* word objects I've stolen */
+      claim_center_tiles: [],   /* center tiles I've taken */
       other_claim_letters: [],  /* letters claimed by another player */
       other_claim_words: [],    /* words stolen by another player */
       claim_error: null,
@@ -258,6 +259,7 @@ class Game extends React.Component {
       claiming: is_me,
       claim_rack: is_me ? [] : this.state.claim_rack,
       claimed_words: is_me ? [] : this.state.claimed_words,
+      claim_center_tiles: is_me ? [] : this.state.claim_center_tiles,
       other_claim_letters: is_me ? [] : (data.claimed_letters || []),
       other_claim_words: is_me ? [] : (data.claimed_words || []),
       claim_error: null,
@@ -277,7 +279,7 @@ class Game extends React.Component {
         t => t.letter === pending && !this.state.revealing[t.id]
       );
       if (tile) {
-        this.take_letter(tile);
+        this.take_center_letter(tile);
       }
     }
   }
@@ -289,6 +291,7 @@ class Game extends React.Component {
       claiming: false,
       claim_rack: [],
       claimed_words: [],
+      claim_center_tiles: [],
       other_claim_letters: [],
       other_claim_words: [],
       claim_error: null,
@@ -404,20 +407,48 @@ class Game extends React.Component {
     }
   }
 
-  async take_letter(tile) {
+  async take_center_letter(tile) {
     const response = await fetch_post_json("take-letter", {
       letter_id: tile.id
     });
     if (response.ok) {
       this.setState(prev => ({
-        claim_rack: [...prev.claim_rack, tile],
+        claim_rack: [...prev.claim_rack, {
+          letter: tile.letter, source: "center", tile
+        }],
+        claim_center_tiles: [...prev.claim_center_tiles, tile],
         center: prev.center.filter(t => t.id !== tile.id),
         claim_error: null
       }));
     }
   }
 
-  async return_letter(tile) {
+  take_word_letter(word_id, idx, letter) {
+    /* Client-side only: move a letter from a stolen word to the rack. */
+    const key = word_id + ":" + idx;
+    /* Don't take the same letter twice. */
+    if (this.state.claim_rack.find(e => e.source === "word"
+        && e.word_id === word_id && e.idx === idx)) return;
+    this.setState(prev => ({
+      claim_rack: [...prev.claim_rack, {
+        letter, source: "word", word_id, idx
+      }],
+      claim_error: null
+    }));
+  }
+
+  return_rack_entry(entry, rack_index) {
+    if (entry.source === "center") {
+      this._return_center_letter(entry.tile, rack_index);
+    } else {
+      /* Word letter: just remove from rack (client-side). */
+      this.setState(prev => ({
+        claim_rack: prev.claim_rack.filter((_, i) => i !== rack_index)
+      }));
+    }
+  }
+
+  async _return_center_letter(tile, rack_index) {
     const response = await fetch_post_json("return-letter", {
       letter_id: tile.id
     });
@@ -427,7 +458,9 @@ class Game extends React.Component {
         positions[tile.id] = this._random_position();
       }
       this.setState(prev => ({
-        claim_rack: prev.claim_rack.filter(t => t.id !== tile.id),
+        claim_rack: prev.claim_rack.filter((_, i) => i !== rack_index),
+        claim_center_tiles: prev.claim_center_tiles.filter(
+          t => t.id !== tile.id),
         center: [...prev.center, tile],
         tile_positions: positions,
         claim_error: null
@@ -456,7 +489,9 @@ class Game extends React.Component {
     const response = await fetch_post_json("return-word", { word_id });
     if (response.ok) {
       this.setState(prev => ({
-        claimed_words: prev.claimed_words.filter(cw => cw.word_id !== word_id)
+        claimed_words: prev.claimed_words.filter(cw => cw.word_id !== word_id),
+        claim_rack: prev.claim_rack.filter(e =>
+          !(e.source === "word" && e.word_id === word_id))
       }));
     }
   }
@@ -618,7 +653,7 @@ class Game extends React.Component {
       e.preventDefault();
       const rack = this.state.claim_rack;
       if (rack.length > 0) {
-        this.return_letter(rack[rack.length - 1]);
+        this.return_rack_entry(rack[rack.length - 1], rack.length - 1);
       }
       return;
     }
@@ -650,15 +685,36 @@ class Game extends React.Component {
 
     e.preventDefault();
 
-    /* Find a matching letter in the center and claim it. */
+    /* First look for the letter in unused stolen-word letters. */
+    if (this._take_word_letter_by_key(key)) return;
+
+    /* Fall back to taking from the center. */
     const tile = this.state.center.find(
       t => t.letter === key && !this.state.revealing[t.id]
     );
     if (tile) {
-      this.take_letter(tile);
+      this.take_center_letter(tile);
     }
   }
 
+  _take_word_letter_by_key(key) {
+    const { claimed_words, claim_rack } = this.state;
+    for (const cw of claimed_words) {
+      const word = cw.word_obj.word;
+      for (let idx = 0; idx < word.length; idx++) {
+        if (word[idx] !== key) continue;
+        /* Check if this specific letter is already used. */
+        const already_used = claim_rack.find(e =>
+          e.source === "word" && e.word_id === cw.word_id && e.idx === idx);
+        if (!already_used) {
+          this.take_word_letter(cw.word_id, idx, word[idx]);
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
   /*****************************************************
    * Center tile dragging (client-side rearrangement)  *
    *****************************************************/
@@ -670,7 +726,7 @@ class Game extends React.Component {
     const r = rack.getBoundingClientRect();
     if (clientX >= r.left && clientX <= r.right
         && clientY >= r.top && clientY <= r.bottom) {
-      this.take_letter(tile);
+      this.take_center_letter(tile);
       return true;
     }
     return false;
@@ -725,7 +781,7 @@ class Game extends React.Component {
         this.setState({ dragging_center_tile: null });
         this._drop_on_rack(me.clientX, me.clientY, tile);
       } else if (this.state.claim_active && !this.state.revealing[tile.id]) {
-        this.take_letter(tile);
+        this.take_center_letter(tile);
       }
     };
 
@@ -801,7 +857,7 @@ class Game extends React.Component {
         this._drop_on_rack(this._drag_last_touch.x, this._drag_last_touch.y, tile);
       }
     } else if (this.state.claim_active && !this.state.revealing[tile.id]) {
-      this.take_letter(tile);
+      this.take_center_letter(tile);
     }
 
     this._drag_tile = null;
@@ -989,25 +1045,45 @@ class Game extends React.Component {
     );
   }
 
+  /* Check if a specific letter position in a stolen word is used. */
+  _is_word_letter_used(word_id, idx) {
+    return !!this.state.claim_rack.find(e =>
+      e.source === "word" && e.word_id === word_id && e.idx === idx);
+  }
+
+  _is_center_tile_used(tile_id) {
+    return !!this.state.claim_rack.find(e =>
+      e.source === "center" && e.tile.id === tile_id);
+  }
+
   render_claim_area() {
-    const { claim_rack, claimed_words, claim_error,
-            claim_warning, claim_remaining_ms } = this.state;
+    const { claim_rack, claimed_words, claim_center_tiles,
+            claim_error, claim_warning, claim_remaining_ms } = this.state;
 
-    const total_letters = claim_rack.length;
-    const can_submit = total_letters >= 4;
+    const can_submit = claim_rack.length >= 4;
+    const has_sources = claimed_words.length > 0
+                     || claim_center_tiles.length > 0;
 
     return (
       <div key="claim" className="claim-area">
         <h3>Your word:</h3>
 
-        {claimed_words.length > 0 ? (
-          <div>
+        {has_sources ? (
+          <div className="claim-items-display">
             {claimed_words.map((cw, i) => [
               i > 0 ? <span key={"sep" + cw.word_id}
                             className="separator">+</span> : null,
               <span key={cw.word_id} className="claimed-word">
                 {cw.word_obj.word.split("").map((ch, j) => (
-                  <Tile key={j} letter={ch} />
+                  <span key={j}
+                        className={"tile" + (this._is_word_letter_used(cw.word_id, j) ? " used" : "")}
+                        onClick={() => {
+                          if (!this._is_word_letter_used(cw.word_id, j)) {
+                            this.take_word_letter(cw.word_id, j, ch);
+                          }
+                        }}>
+                    {ch}
+                  </span>
                 ))}
                 <span className="remove-word-btn"
                       onClick={() => this.return_word(cw.word_id)}>
@@ -1015,15 +1091,37 @@ class Game extends React.Component {
                 </span>
               </span>
             ])}
+            {claim_center_tiles.map((tile, i) => [
+              (i > 0 || claimed_words.length > 0)
+                ? <span key={"sep-c" + tile.id}
+                        className="separator">+</span> : null,
+              <span key={tile.id} className="claimed-word">
+                <span className={"tile" + (this._is_center_tile_used(tile.id) ? " used" : "")}
+                      onClick={() => {
+                        if (!this._is_center_tile_used(tile.id)) {
+                          this.setState(prev => ({
+                            claim_rack: [...prev.claim_rack, {
+                              letter: tile.letter, source: "center", tile
+                            }]
+                          }));
+                        }
+                      }}>
+                  {tile.letter}
+                </span>
+              </span>
+            ])}
           </div>
         ) : null}
 
         <div className="claim-rack">
-          {claim_rack.map(tile => (
-            <Tile key={tile.id}
-                  letter={tile.letter}
-                  onClick={() => this.return_letter(tile)}
-            />
+          {claim_rack.map((entry, i) => (
+            <span key={i} className="rack-tile-wrapper">
+              <Tile letter={entry.letter} />
+              <span className="remove-tile-btn"
+                    onClick={() => this.return_rack_entry(entry, i)}>
+                &#x2715;
+              </span>
+            </span>
           ))}
         </div>