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,
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,
t => t.letter === pending && !this.state.revealing[t.id]
);
if (tile) {
- this.take_letter(tile);
+ this.take_center_letter(tile);
}
}
}
claiming: false,
claim_rack: [],
claimed_words: [],
+ claim_center_tiles: [],
other_claim_letters: [],
other_claim_words: [],
claim_error: null,
}
}
- 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
});
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
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))
}));
}
}
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;
}
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) *
*****************************************************/
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;
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);
}
};
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;
);
}
+ /* 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)}>
</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)}>
+ ✕
+ </span>
+ </span>
))}
</div>