696 lines
22 KiB
JavaScript
696 lines
22 KiB
JavaScript
import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module';
|
|
import wsManager from './websocket.js';
|
|
|
|
/**
|
|
* <crossword-grid>
|
|
* Simple Lit-based grid component for testing the crossword UI.
|
|
* - Attributes: rows, cols (numbers)
|
|
* - Public behavior: listens for 'key-press' CustomEvents on window and
|
|
* places letters / navigates accordingly.
|
|
* - Dispatches 'cell-selected' when user taps a cell.
|
|
*/
|
|
export class CrosswordGrid extends LitElement {
|
|
static properties = {
|
|
rows: { type: Number },
|
|
cols: { type: Number },
|
|
_grid: { state: true },
|
|
_selected: { state: true },
|
|
_inputMode: { state: true }, // 'horizontal' or 'vertical'
|
|
_solvedCells: { state: true }, // tracks which cells are solved
|
|
_clueNumbers: { state: true }, // map of "row,col" -> { across: number, down: number }
|
|
_solutionIndices: { state: true }, // map of "row,col" -> solution index
|
|
_solutionWordPositions: { state: true }, // list of [col, row] positions for solution word
|
|
_solutionWordValues: { state: true }, // map of index -> letter for solution word
|
|
_solutionWordSolved: { state: true }, // set of solution word indices that are solved
|
|
};
|
|
|
|
// styles moved to webui/styles.css; render into light DOM so external CSS applies
|
|
|
|
constructor() {
|
|
super();
|
|
this.rows = 10;
|
|
this.cols = 10;
|
|
this._grid = [];
|
|
this._selected = { r: 0, c: 0 };
|
|
this._inputMode = 'horizontal'; // default input mode
|
|
this._solvedCells = new Set(); // set of "r,c" strings for solved cells
|
|
this._clueNumbers = new Map(); // map of "row,col" -> { across: number, down: number }
|
|
this._solutionIndices = new Map(); // map of "row,col" -> solution index (1-indexed)
|
|
this._solutionWordPositions = []; // list of [col, row] positions
|
|
this._solutionWordValues = new Map(); // map of index -> letter
|
|
this._solutionWordSolved = new Set(); // set of solution word indices that are solved
|
|
this.sessionId = null; // Session ID for sending updates to server
|
|
}
|
|
|
|
createRenderRoot() { return this; }
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
// initialize empty grid
|
|
this._ensureGrid();
|
|
// listen for keyboard (mobile) events
|
|
this._keyHandler = (e) => this._onKeyPress(e.detail);
|
|
window.addEventListener('key-press', this._keyHandler);
|
|
this._computeCellSize();
|
|
this._resizeHandler = () => this._computeCellSize();
|
|
window.addEventListener('resize', this._resizeHandler);
|
|
// make the element focusable so it can receive physical keyboard events
|
|
this.setAttribute('tabindex', '0');
|
|
this._keydownHandler = (e) => this._onKeydown(e);
|
|
this.addEventListener('keydown', this._keydownHandler);
|
|
// Listen for letter updates from server
|
|
this._letterUpdateHandler = (msg) => this._onLetterUpdateFromServer(msg);
|
|
wsManager.onMessage('letter_update', this._letterUpdateHandler);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
window.removeEventListener('key-press', this._keyHandler);
|
|
this.removeEventListener('keydown', this._keydownHandler);
|
|
window.removeEventListener('resize', this._resizeHandler);
|
|
wsManager.offMessage('letter_update', this._letterUpdateHandler);
|
|
}
|
|
|
|
_ensureGrid() {
|
|
if (!this._grid || this._grid.length !== this.rows) {
|
|
this._grid = Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => ''));
|
|
}
|
|
}
|
|
|
|
render() {
|
|
this._ensureGrid();
|
|
// set CSS variables for cell-size and column count; layout done in external stylesheet
|
|
return html`
|
|
<div class="main-grid-scroll-container">
|
|
<div class="grid-container ${this._isSolutionWordComplete() ? 'complete' : ''}">
|
|
<div class="grid" style="--cell-size: ${this._cellSize}px; --cols: ${this.cols};">
|
|
${this._grid.map((row, r) => row.map((cell, c) => this._renderCell(r, c, cell))).flat()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${this._solutionWordPositions.length > 0 ? html`
|
|
<div class="solution-scroll-container">
|
|
<h2 style="text-align: center;">Solution Word</h2>
|
|
<div class="grid solution-word-grid" style="--cell-size: 40px; --cols: ${this._solutionWordPositions.length};">
|
|
${this._solutionWordPositions.map((pos, i) => this._renderSolutionCell(i, pos))}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
}
|
|
|
|
updated(changedProperties) {
|
|
super.updated(changedProperties);
|
|
// Set pulse animation delays when grid becomes complete
|
|
if (this._isSolutionWordComplete()) {
|
|
this._setPulseDelays();
|
|
}
|
|
}
|
|
|
|
_setPulseDelays() {
|
|
const gridContainer = this.querySelector('.grid-container');
|
|
if (gridContainer && gridContainer.classList.contains('complete')) {
|
|
const cells = gridContainer.querySelectorAll('.cell');
|
|
cells.forEach((cell, index) => {
|
|
// Calculate row and column from index
|
|
const row = Math.floor(index / this.cols);
|
|
const col = index % this.cols;
|
|
// Diagonal wave: delay based on row + col (top-left to bottom-right)
|
|
const diagonalIndex = row + col;
|
|
cell.style.setProperty('--pulse-delay', `${diagonalIndex * 0.1}s`);
|
|
});
|
|
}
|
|
}
|
|
|
|
_renderSolutionCell(index, position) {
|
|
const letter = this._solutionWordValues.get(index) || '';
|
|
const isSolved = this._solutionWordSolved.has(index);
|
|
const classes = ['cell'];
|
|
if (isSolved) classes.push('solved');
|
|
|
|
return html`
|
|
<div class="${classes.join(' ')}" data-solution-index="${index}" data-row="${position[1]}" data-col="${position[0]}" @click=${() => this._onSolutionCellClick(index, position)}>
|
|
<div class="solution-circle"></div>
|
|
<span class="solution-index">${index + 1}</span>
|
|
<span class="cell-letter">${letter}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
|
|
_computeCellSize() {
|
|
// compute a comfortable cell size depending on viewport width and number of columns
|
|
try {
|
|
const maxWidth = Math.min(window.innerWidth * 0.92, 720); // keep some margin
|
|
let size = Math.floor((maxWidth - 16) / this.cols);
|
|
// clamp sizes
|
|
if (window.innerWidth <= 450) {
|
|
// on very small screens, allow larger tap targets up to 56px
|
|
size = Math.min(Math.max(size, 44), 64);
|
|
} else if (window.innerWidth <= 900) {
|
|
size = Math.min(Math.max(size, 40), 56);
|
|
} else {
|
|
size = Math.min(Math.max(size, 40), 48);
|
|
}
|
|
this._cellSize = size;
|
|
this.requestUpdate();
|
|
} catch (e) {
|
|
// fallback
|
|
this._cellSize = 44;
|
|
}
|
|
}
|
|
|
|
_renderCell(r, c, value) {
|
|
const classes = ['cell'];
|
|
const cellKey = `${r},${c}`;
|
|
const isSolved = this._solvedCells.has(cellKey);
|
|
|
|
if (value === '#') classes.push('wall');
|
|
if (isSolved) classes.push('solved');
|
|
if (this._selected.r === r && this._selected.c === c) classes.push('selected');
|
|
|
|
// Check if this cell is in the highlighted row/column based on input mode
|
|
// But only if it's not a wall
|
|
if (value !== '#') {
|
|
if (this._inputMode === 'horizontal' && r === this._selected.r && this._isInHorizontalLine(r, c)) {
|
|
classes.push('mode-highlighted');
|
|
} else if (this._inputMode === 'vertical' && c === this._selected.c && this._isInVerticalLine(r, c)) {
|
|
classes.push('mode-highlighted');
|
|
}
|
|
}
|
|
|
|
// Get clue numbers for this cell
|
|
const clueInfo = this._clueNumbers.get(cellKey);
|
|
let clueNumberDisplay = '';
|
|
if (clueInfo) {
|
|
if (clueInfo.across !== null && clueInfo.down !== null) {
|
|
// Both across and down clues: show "across/down" format
|
|
clueNumberDisplay = `${clueInfo.across}/${clueInfo.down}`;
|
|
} else if (clueInfo.across !== null) {
|
|
// Only across clue
|
|
clueNumberDisplay = String(clueInfo.across);
|
|
} else if (clueInfo.down !== null) {
|
|
// Only down clue
|
|
clueNumberDisplay = String(clueInfo.down);
|
|
}
|
|
}
|
|
|
|
// Get solution index for this cell
|
|
const solutionIndex = this._solutionIndices.get(cellKey);
|
|
|
|
const cellContent = clueNumberDisplay
|
|
? html`<span class="clue-number">${clueNumberDisplay}</span><span class="cell-letter">${value}</span>`
|
|
: html`<span class="cell-letter">${value}</span>`;
|
|
|
|
const cellHTML = solutionIndex !== undefined
|
|
? html`${cellContent}<div class="solution-circle"></div><span class="solution-index">${solutionIndex}</span>`
|
|
: cellContent;
|
|
|
|
return html`<div class="${classes.join(' ')}" @click=${() => this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${cellHTML}</div>`;
|
|
}
|
|
|
|
/**
|
|
* Get the length of the horizontal line at the selected cell
|
|
*/
|
|
_getHorizontalLineLength(r = this._selected.r, c = this._selected.c) {
|
|
let start = c, end = c;
|
|
|
|
// Expand left
|
|
while (start > 0 && this._grid[r][start - 1] !== '#') {
|
|
start--;
|
|
}
|
|
// Expand right
|
|
while (end < this.cols - 1 && this._grid[r][end + 1] !== '#') {
|
|
end++;
|
|
}
|
|
|
|
return end - start + 1;
|
|
}
|
|
|
|
/**
|
|
* Check if cell (r, c) is part of the horizontal line from the selected cell
|
|
* (i.e., same row and not blocked by walls before/after this cell)
|
|
*/
|
|
_isInHorizontalLine(r, c) {
|
|
const selectedRow = this._selected.r;
|
|
if (r !== selectedRow) return false;
|
|
|
|
const selectedCol = this._selected.c;
|
|
// Find the start and end of the continuous line in this row
|
|
let start = selectedCol, end = selectedCol;
|
|
|
|
// Expand left
|
|
while (start > 0 && this._grid[r][start - 1] !== '#') {
|
|
start--;
|
|
}
|
|
// Expand right
|
|
while (end < this.cols - 1 && this._grid[r][end + 1] !== '#') {
|
|
end++;
|
|
}
|
|
|
|
return c >= start && c <= end;
|
|
}
|
|
|
|
/**
|
|
* Get the length of the vertical line at the selected cell
|
|
*/
|
|
_getVerticalLineLength(r = this._selected.r, c = this._selected.c) {
|
|
let start = r, end = r;
|
|
|
|
// Expand up
|
|
while (start > 0 && this._grid[start - 1][c] !== '#') {
|
|
start--;
|
|
}
|
|
// Expand down
|
|
while (end < this.rows - 1 && this._grid[end + 1][c] !== '#') {
|
|
end++;
|
|
}
|
|
|
|
return end - start + 1;
|
|
}
|
|
|
|
/**
|
|
* Check if the entire solution word is solved
|
|
*/
|
|
_isSolutionWordComplete() {
|
|
if (this._solutionWordPositions.length === 0) return false;
|
|
return this._solutionWordPositions.every((_, i) => this._solutionWordSolved.has(i));
|
|
}
|
|
|
|
/**
|
|
* Check if cell (r, c) is part of the vertical line from the selected cell
|
|
* (i.e., same column and not blocked by walls above/below this cell)
|
|
*/
|
|
_isInVerticalLine(r, c) {
|
|
const selectedCol = this._selected.c;
|
|
if (c !== selectedCol) return false;
|
|
|
|
const selectedRow = this._selected.r;
|
|
// Find the start and end of the continuous line in this column
|
|
let start = selectedRow, end = selectedRow;
|
|
|
|
// Expand up
|
|
while (start > 0 && this._grid[start - 1][c] !== '#') {
|
|
start--;
|
|
}
|
|
// Expand down
|
|
while (end < this.rows - 1 && this._grid[end + 1][c] !== '#') {
|
|
end++;
|
|
}
|
|
|
|
return r >= start && r <= end;
|
|
}
|
|
|
|
_onCellClick(r, c, preferredMode = null) {
|
|
// if same cell is clicked again, toggle the input mode
|
|
if (this._selected.r === r && this._selected.c === c) {
|
|
// If a preferred mode is provided, use it (don't toggle)
|
|
if (preferredMode) {
|
|
this._inputMode = preferredMode;
|
|
} else {
|
|
this._inputMode = this._inputMode === 'horizontal' ? 'vertical' : 'horizontal';
|
|
}
|
|
} else {
|
|
// select a new cell
|
|
this._selected = { r, c };
|
|
|
|
// Use preferred mode if provided, otherwise auto-select based on line lengths
|
|
if (preferredMode) {
|
|
this._inputMode = preferredMode;
|
|
} else {
|
|
// auto-select mode based on line lengths
|
|
const horizontalLength = this._getHorizontalLineLength(r, c);
|
|
const verticalLength = this._getVerticalLineLength(r, c);
|
|
|
|
// if one mode only has 1 cell but the other has multiple, use the one with multiple
|
|
if (horizontalLength === 1 && verticalLength > 1) {
|
|
this._inputMode = 'vertical';
|
|
} else if (verticalLength === 1 && horizontalLength > 1) {
|
|
this._inputMode = 'horizontal';
|
|
}
|
|
// otherwise keep current mode (both >1 or both =1)
|
|
}
|
|
}
|
|
this.requestUpdate();
|
|
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: r, col: c, mode: this._inputMode }, bubbles: true, composed: true }));
|
|
// focus the element so keyboard input goes to the grid
|
|
this.focus();
|
|
}
|
|
|
|
_onSolutionCellClick(index, position) {
|
|
// When clicking a solution word cell, select the corresponding grid cell
|
|
const [col, row] = position;
|
|
this._onCellClick(row, col);
|
|
}
|
|
|
|
_onKeydown(e) {
|
|
// Only handle keys when the grid has focus
|
|
// Map letters, arrows and backspace to our handlers
|
|
const key = e.key;
|
|
if (!key) return;
|
|
// letters (accept single-character a-z)
|
|
if (/^[a-zA-Z]$/.test(key)) {
|
|
e.preventDefault();
|
|
this._onKeyPress({ type: 'letter', value: key.toLowerCase() });
|
|
return;
|
|
}
|
|
// spacebar - treat as a letter input (empty string)
|
|
if (key === ' ') {
|
|
e.preventDefault();
|
|
this._onKeyPress({ type: 'letter', value: ' ' });
|
|
return;
|
|
}
|
|
// navigation arrows
|
|
if (key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const dir = key.replace('Arrow', '').toLowerCase();
|
|
this._onKeyPress({ type: 'navigate', value: dir });
|
|
return;
|
|
}
|
|
// backspace/delete
|
|
if (key === 'Backspace' || key === 'Delete') {
|
|
e.preventDefault();
|
|
this._onKeyPress({ type: 'backspace' });
|
|
return;
|
|
}
|
|
} _onKeyPress(detail) {
|
|
if (!detail) return;
|
|
const { type, value } = detail;
|
|
if (type === 'letter') {
|
|
this._placeLetter(value);
|
|
} else if (type === 'navigate') {
|
|
this._navigate(value);
|
|
} else if (type === 'backspace') {
|
|
this._handleBackspace();
|
|
}
|
|
}
|
|
|
|
_handleBackspace() {
|
|
const { r, c } = this._selected;
|
|
const cellKey = `${r},${c}`;
|
|
|
|
// ignore walls
|
|
if (this._grid[r][c] === '#') return;
|
|
|
|
// If it's a solved cell, just navigate back without changing it
|
|
if (this._solvedCells.has(cellKey)) {
|
|
if (this._inputMode === 'horizontal') {
|
|
this._moveToNextCell(r, c, 'left');
|
|
} else { // vertical
|
|
this._moveToNextCell(r, c, 'up');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// delete the letter at current cell (only for non-solved cells)
|
|
this._grid = this._grid.map((row, ri) => row.map((cell, ci) => (ri === r && ci === c ? '' : cell)));
|
|
|
|
// Send letter update to server (empty string for cleared cell)
|
|
if (this.sessionId && wsManager.isConnected()) {
|
|
const message = {
|
|
type: 'update_letter',
|
|
session_id: this.sessionId,
|
|
row: r,
|
|
col: c,
|
|
letter: ''
|
|
};
|
|
wsManager.send(message);
|
|
}
|
|
|
|
// move to previous cell based on input mode
|
|
if (this._inputMode === 'horizontal') {
|
|
this._moveToNextCell(r, c, 'left');
|
|
} else { // vertical
|
|
this._moveToNextCell(r, c, 'up');
|
|
}
|
|
this.requestUpdate();
|
|
}
|
|
|
|
_placeLetter(letter) {
|
|
const { r, c } = this._selected;
|
|
const cellKey = `${r},${c}`;
|
|
const currentLetter = this._grid[r][c];
|
|
|
|
// ignore walls
|
|
if (currentLetter === '#') return;
|
|
|
|
// For solved cells, only navigate (don't send updates)
|
|
if (this._solvedCells.has(cellKey)) {
|
|
// Only move if space or letter matches
|
|
if (letter === ' ' || letter.toUpperCase() === currentLetter.toUpperCase()) {
|
|
if (this._inputMode === 'horizontal') {
|
|
this._moveToNextCell(r, c, 'right');
|
|
} else { // vertical
|
|
this._moveToNextCell(r, c, 'down');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check if the letter is the same as what's already there (case insensitive)
|
|
const isSameLetter = letter !== ' ' && letter.toUpperCase() === currentLetter.toUpperCase();
|
|
|
|
// Only update grid and send message if it's a different letter
|
|
if (!isSameLetter) {
|
|
this._grid = this._grid.map((row, ri) => row.map((cell, ci) => (ri === r && ci === c ? letter : cell)));
|
|
|
|
// Send letter update to server (empty string for space)
|
|
if (this.sessionId && wsManager.isConnected()) {
|
|
const message = {
|
|
type: 'update_letter',
|
|
session_id: this.sessionId,
|
|
row: r,
|
|
col: c,
|
|
letter: letter === ' ' ? '' : letter
|
|
};
|
|
wsManager.send(message);
|
|
}
|
|
}
|
|
|
|
// move to next cell if:
|
|
// - space was pressed, OR
|
|
// - the letter is the same as what was already there, OR
|
|
// - a new letter was placed
|
|
const shouldMove = letter === ' ' || isSameLetter || letter !== '';
|
|
|
|
if (shouldMove) {
|
|
if (this._inputMode === 'horizontal') {
|
|
this._moveToNextCell(r, c, 'right');
|
|
} else { // vertical
|
|
this._moveToNextCell(r, c, 'down');
|
|
}
|
|
}
|
|
this.requestUpdate();
|
|
}
|
|
|
|
/**
|
|
* Move from current cell to next valid cell in the given direction
|
|
*/
|
|
_moveToNextCell(r, c, direction) {
|
|
let nr = r, nc = c;
|
|
if (direction === 'right') nc += 1;
|
|
else if (direction === 'left') nc -= 1;
|
|
else if (direction === 'down') nr += 1;
|
|
else if (direction === 'up') nr -= 1;
|
|
|
|
// check bounds and walls
|
|
if (nr >= 0 && nr < this.rows && nc >= 0 && nc < this.cols && this._grid[nr][nc] !== '#') {
|
|
this._selected = { r: nr, c: nc };
|
|
}
|
|
}
|
|
|
|
_navigate(direction) {
|
|
// Check if the arrow direction matches the current input mode
|
|
// If not, switch modes instead of navigating
|
|
const isHorizontalArrow = direction === 'left' || direction === 'right';
|
|
const isVerticalArrow = direction === 'up' || direction === 'down';
|
|
|
|
if (this._inputMode === 'horizontal' && isVerticalArrow) {
|
|
// User pressed up/down arrow but mode is horizontal, switch to vertical
|
|
this._inputMode = 'vertical';
|
|
this.requestUpdate();
|
|
// Emit event for mode change
|
|
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: this._selected.r, col: this._selected.c, mode: this._inputMode }, bubbles: true, composed: true }));
|
|
return;
|
|
}
|
|
|
|
if (this._inputMode === 'vertical' && isHorizontalArrow) {
|
|
// User pressed left/right arrow but mode is vertical, switch to horizontal
|
|
this._inputMode = 'horizontal';
|
|
this.requestUpdate();
|
|
// Emit event for mode change
|
|
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: this._selected.r, col: this._selected.c, mode: this._inputMode }, bubbles: true, composed: true }));
|
|
return;
|
|
}
|
|
|
|
// Direction matches mode, navigate normally
|
|
const { r, c } = this._selected;
|
|
let nr = r, nc = c;
|
|
if (direction === 'left') nc = Math.max(0, c - 1);
|
|
if (direction === 'right') nc = Math.min(this.cols - 1, c + 1);
|
|
if (direction === 'up') nr = Math.max(0, r - 1);
|
|
if (direction === 'down') nr = Math.min(this.rows - 1, r + 1);
|
|
|
|
// Check for walls
|
|
if (this._grid[nr] && this._grid[nr][nc] === '#') {
|
|
return; // Don't navigate into walls
|
|
}
|
|
|
|
this._selected = { r: nr, c: nc };
|
|
this.requestUpdate();
|
|
// Emit event for navigation
|
|
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: nr, col: nc, mode: this._inputMode }, bubbles: true, composed: true }));
|
|
}
|
|
|
|
// convenience method to set grid walls (for demo)
|
|
setWalls(wallPositions = []) {
|
|
wallPositions.forEach(([r, c]) => {
|
|
if (r >= 0 && r < this.rows && c >= 0 && c < this.cols) {
|
|
this._grid[r][c] = '#';
|
|
}
|
|
});
|
|
this.requestUpdate();
|
|
}
|
|
|
|
/**
|
|
* Handle letter updates from server (broadcast messages from other players)
|
|
*/
|
|
_onLetterUpdateFromServer(message) {
|
|
const { row, col, letter, is_solved } = message;
|
|
|
|
// Update grid if within bounds
|
|
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
|
|
this._grid = this._grid.map((gridRow, ri) =>
|
|
gridRow.map((cell, ci) => (ri === row && ci === col ? letter : cell))
|
|
);
|
|
|
|
// Update solved status
|
|
const cellKey = `${row},${col}`;
|
|
if (is_solved) {
|
|
this._solvedCells.add(cellKey);
|
|
} else {
|
|
this._solvedCells.delete(cellKey);
|
|
}
|
|
|
|
// Update solution word if this position is part of it
|
|
for (let i = 0; i < this._solutionWordPositions.length; i++) {
|
|
const [col_sw, row_sw] = this._solutionWordPositions[i];
|
|
if (row === row_sw && col === col_sw) {
|
|
this._solutionWordValues.set(i, letter);
|
|
// Mark solution word cell as solved
|
|
if (is_solved) {
|
|
this._solutionWordSolved.add(i);
|
|
} else {
|
|
this._solutionWordSolved.delete(i);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.requestUpdate();
|
|
|
|
// Trigger animation if solution word just completed
|
|
if (this._isSolutionWordComplete()) {
|
|
this.updateComplete.then(() => {
|
|
const gridContainer = this.querySelector('.solution-word-grid');
|
|
if (gridContainer) {
|
|
// Force reflow to trigger animation
|
|
gridContainer.offsetHeight;
|
|
gridContainer.classList.remove('complete');
|
|
gridContainer.offsetHeight;
|
|
gridContainer.classList.add('complete');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Emit a letter-changed event so solution word can update
|
|
this.dispatchEvent(new CustomEvent('letter-changed', {
|
|
detail: { row, col, letter, is_solved },
|
|
bubbles: true,
|
|
composed: true
|
|
}));
|
|
|
|
console.log(`Letter update from server: [${row}, ${col}] = "${letter}" (solved: ${is_solved})`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Populate clue numbers from server data
|
|
* @param {Object} cluePositionsAcross - dict of clue_number -> [col, row]
|
|
* @param {Object} cluePositionsDown - dict of clue_number -> [col, row]
|
|
*/
|
|
populateClueNumbers(cluePositionsAcross = {}, cluePositionsDown = {}) {
|
|
this._clueNumbers.clear();
|
|
|
|
// Add across clues
|
|
for (const [clueNum, position] of Object.entries(cluePositionsAcross)) {
|
|
const [col, row] = position;
|
|
const cellKey = `${row},${col}`;
|
|
|
|
if (!this._clueNumbers.has(cellKey)) {
|
|
this._clueNumbers.set(cellKey, { across: null, down: null });
|
|
}
|
|
|
|
this._clueNumbers.get(cellKey).across = parseInt(clueNum);
|
|
}
|
|
|
|
// Add down clues
|
|
for (const [clueNum, position] of Object.entries(cluePositionsDown)) {
|
|
const [col, row] = position;
|
|
const cellKey = `${row},${col}`;
|
|
|
|
if (!this._clueNumbers.has(cellKey)) {
|
|
this._clueNumbers.set(cellKey, { across: null, down: null });
|
|
}
|
|
|
|
this._clueNumbers.get(cellKey).down = parseInt(clueNum);
|
|
}
|
|
|
|
this.requestUpdate();
|
|
}
|
|
|
|
/**
|
|
* Populate solution word indices from server data
|
|
* @param {Array} solutionPositions - list of [col, row] positions in order
|
|
*/
|
|
populateSolutionIndices(solutionPositions = []) {
|
|
this._solutionIndices.clear();
|
|
this._solutionWordPositions = solutionPositions;
|
|
this._solutionWordValues.clear();
|
|
this._solutionWordSolved.clear();
|
|
|
|
for (let i = 0; i < solutionPositions.length; i++) {
|
|
const [col, row] = solutionPositions[i];
|
|
const cellKey = `${row},${col}`;
|
|
this._solutionIndices.set(cellKey, i + 1); // 1-indexed
|
|
|
|
// Initialize solution word value with current grid letter
|
|
const letter = this._grid[row][col] || '';
|
|
this._solutionWordValues.set(i, letter);
|
|
|
|
// Check if this position is already solved
|
|
if (this._solvedCells.has(cellKey)) {
|
|
this._solutionWordSolved.add(i);
|
|
}
|
|
}
|
|
|
|
console.log('Solution word initialized. Solved:', this._solutionWordSolved.size, 'Total:', this._solutionWordPositions.length);
|
|
this.requestUpdate();
|
|
|
|
// Trigger animation on init if already complete
|
|
if (this._isSolutionWordComplete()) {
|
|
this.updateComplete.then(() => {
|
|
const gridContainer = this.querySelector('.solution-word-grid');
|
|
if (gridContainer) {
|
|
// Force reflow to trigger animation
|
|
gridContainer.offsetHeight;
|
|
gridContainer.classList.remove('complete');
|
|
gridContainer.offsetHeight;
|
|
gridContainer.classList.add('complete');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define('crossword-grid', CrosswordGrid); |