embed puzzle player
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Grid from './Grid'
|
||||
import CluePanel from './CluePanel'
|
||||
import ActiveClue from './ActiveClue'
|
||||
import { useKeyboard } from '../hooks/useKeyboard'
|
||||
import { useStore } from '../store'
|
||||
import type { ClientPuzzle } from '../types'
|
||||
|
||||
interface Props {
|
||||
puzzleId: string
|
||||
showTimer: boolean
|
||||
showHints: boolean
|
||||
}
|
||||
|
||||
function buildWordSpans(puzzle: ClientPuzzle) {
|
||||
const spans: { number: number; direction: 'across' | 'down'; cells: [number, number][] }[] = []
|
||||
for (const clue of puzzle.clues_across) {
|
||||
const key = Object.entries(puzzle.cell_numbers).find(([, n]) => n === clue.number)?.[0]
|
||||
if (!key) continue
|
||||
const [r, c] = key.split(',').map(Number)
|
||||
const cells: [number, number][] = []
|
||||
for (let j = c; j < puzzle.width && puzzle.cells[r][j] !== 'black'; j++) cells.push([r, j])
|
||||
spans.push({ number: clue.number, direction: 'across', cells })
|
||||
}
|
||||
for (const clue of puzzle.clues_down) {
|
||||
const key = Object.entries(puzzle.cell_numbers).find(([, n]) => n === clue.number)?.[0]
|
||||
if (!key) continue
|
||||
const [r, c] = key.split(',').map(Number)
|
||||
const cells: [number, number][] = []
|
||||
for (let i = r; i < puzzle.height && puzzle.cells[i][c] !== 'black'; i++) cells.push([i, c])
|
||||
spans.push({ number: clue.number, direction: 'down', cells })
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
function makeEmptyGrid(w: number, h: number) {
|
||||
return Array.from({ length: h }, () => Array(w).fill(''))
|
||||
}
|
||||
|
||||
function makeStatusGrid(w: number, h: number) {
|
||||
return Array.from({ length: h }, () => Array(w).fill('default'))
|
||||
}
|
||||
|
||||
export default function EmbedPlayer({ puzzleId, showTimer, showHints }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const puzzle = useStore(s => s.puzzle)
|
||||
const solved = useStore(s => s.solved)
|
||||
const elapsed = useStore(s => s.elapsed)
|
||||
const startTime = useStore(s => s.startTime)
|
||||
const paused = useStore(s => s.paused)
|
||||
const tick = useStore(s => s.tick)
|
||||
useKeyboard()
|
||||
|
||||
// timer
|
||||
useEffect(() => {
|
||||
if (!startTime || paused || !showTimer) return
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [startTime, paused, tick, showTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (!puzzleId) {
|
||||
const t0 = setTimeout(() => { setError(t('embed.noPuzzleId')); setLoading(false) }, 0)
|
||||
return () => clearTimeout(t0)
|
||||
}
|
||||
|
||||
fetch(`/api/puzzles/s/${puzzleId}`)
|
||||
.then(r => { if (!r.ok) throw new Error(t('embed.notFound')); return r.json() })
|
||||
.then(data => {
|
||||
// SAFETY: /api/puzzles/s/{id} always answers with the ClientPuzzle shape for existing ids
|
||||
const p = data.puzzle as ClientPuzzle
|
||||
const spans = buildWordSpans(p)
|
||||
useStore.setState({
|
||||
puzzleId: data.id,
|
||||
shortId: data.short_id,
|
||||
puzzle: p,
|
||||
entries: makeEmptyGrid(p.width, p.height),
|
||||
cellStatus: makeStatusGrid(p.width, p.height),
|
||||
selectedCell: null,
|
||||
direction: 'across',
|
||||
activeClue: null,
|
||||
wordSpans: spans,
|
||||
loading: false,
|
||||
startTime: Date.now(),
|
||||
elapsed: 0,
|
||||
paused: false,
|
||||
solved: false,
|
||||
hintsUsed: 0,
|
||||
})
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(e => { setError(e.message); setLoading(false) })
|
||||
}, [puzzleId])
|
||||
|
||||
// notify parent on completion
|
||||
useEffect(() => {
|
||||
if (solved && window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'cruciverb:complete', elapsed }, '*')
|
||||
}
|
||||
}, [solved, elapsed])
|
||||
|
||||
if (loading) return <div className="embed-loading"><div className="spinner" /></div>
|
||||
if (error) return <div className="embed-error">{error}</div>
|
||||
if (!puzzle) return null
|
||||
|
||||
const mins = Math.floor(elapsed / 60)
|
||||
const secs = elapsed % 60
|
||||
|
||||
return (
|
||||
<div className="embed-player">
|
||||
{solved && <div className="embed-solved">{t('embed.solved')}</div>}
|
||||
<div className="embed-header">
|
||||
<span className="embed-brand">{t('embed.brand')}</span>
|
||||
{showTimer && <span className="embed-timer">{mins}:{secs.toString().padStart(2, '0')}</span>}
|
||||
</div>
|
||||
<ActiveClue />
|
||||
<div className="embed-game">
|
||||
<div className="embed-grid">
|
||||
<Grid />
|
||||
</div>
|
||||
<CluePanel />
|
||||
</div>
|
||||
{showHints && !solved && (
|
||||
<div className="embed-hints">
|
||||
<button className="btn btn-sm" onClick={() => useStore.getState().checkCell()}>{t('game.check')}</button>
|
||||
<button className="btn btn-sm" onClick={() => useStore.getState().revealLetter()}>{t('game.reveal')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user