From: Carl Worth Date: Sun, 8 Mar 2026 16:18:08 +0000 (-0400) Subject: anagrams: Fix claim interface to work for stealing a word X-Git-Url: https://git.cworth.org/git?a=commitdiff_plain;h=5a8597e82452fa0e3e2644284dbefeebb012105c;p=lmno.games anagrams: Fix claim interface to work for stealing a word 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. --- diff --git a/anagrams/anagrams.css b/anagrams/anagrams.css index 5ab18cc..620465b 100644 --- a/anagrams/anagrams.css +++ b/anagrams/anagrams.css @@ -183,6 +183,39 @@ 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; diff --git a/anagrams/anagrams.jsx b/anagrams/anagrams.jsx index 8fac388..712e21c 100644 --- a/anagrams/anagrams.jsx +++ b/anagrams/anagrams.jsx @@ -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 (

Your word:

- {claimed_words.length > 0 ? ( -
+ {has_sources ? ( +
{claimed_words.map((cw, i) => [ i > 0 ? + : null, {cw.word_obj.word.split("").map((ch, j) => ( - + { + if (!this._is_word_letter_used(cw.word_id, j)) { + this.take_word_letter(cw.word_id, j, ch); + } + }}> + {ch} + ))} this.return_word(cw.word_id)}> @@ -1015,15 +1091,37 @@ class Game extends React.Component { ])} + {claim_center_tiles.map((tile, i) => [ + (i > 0 || claimed_words.length > 0) + ? + : null, + + { + if (!this._is_center_tile_used(tile.id)) { + this.setState(prev => ({ + claim_rack: [...prev.claim_rack, { + letter: tile.letter, source: "center", tile + }] + })); + } + }}> + {tile.letter} + + + ])}
) : null}
- {claim_rack.map(tile => ( - this.return_letter(tile)} - /> + {claim_rack.map((entry, i) => ( + + + this.return_rack_entry(entry, i)}> + ✕ + + ))}