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 } } */
/* 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 *
*****************************************************/
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);
}
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,
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) {
receive_game_over(data) {
this.setState({
game_over: true,
- final_scores: data.scores,
- game_steal: data.game_steal
+ final_scores: data.scores
});
}
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) {
};
}
+ /*****************************************************
+ * 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 *
*****************************************************/
}
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.
) : 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>
}
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);
))}
</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>
];
}
}