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