From 9f6d583aa0a8139f8891d2e9659bd2be582d11d7 Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Sun, 8 Mar 2026 00:22:37 -0500 Subject: [PATCH] Fix Anagrams: draggable center tiles, keyboard input, countdown animation, claim bugs 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 --- anagrams/anagrams.css | 8 +- anagrams/anagrams.jsx | 266 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 221 insertions(+), 53 deletions(-) diff --git a/anagrams/anagrams.css b/anagrams/anagrams.css index c194c3c..4159125 100644 --- a/anagrams/anagrams.css +++ b/anagrams/anagrams.css @@ -112,14 +112,8 @@ .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) */ diff --git a/anagrams/anagrams.jsx b/anagrams/anagrams.jsx index 8969fc1..caaf382 100644 --- a/anagrams/anagrams.jsx +++ b/anagrams/anagrams.jsx @@ -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 ( -
+
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 ? (
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 ( - this.take_letter(tile) : null} - /> +
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} +
); })}
@@ -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 { ))}
))} -
, - game_steal ? ( -
- The game steals {game_steal.words.map(w => w.word).join(" + ")} to - make {game_steal.result}! -
- ) : null + ]; } } -- 2.45.2