diff --git a/docs/pyquest-test.mdx b/docs/pyquest-test.mdx new file mode 100644 index 0000000..144c174 --- /dev/null +++ b/docs/pyquest-test.mdx @@ -0,0 +1,84 @@ +--- +unlisted: true +title: PyQuest — pagina di test +description: Harness di verifica del motore + renderer PyQuest (mini-giochi a griglia) +--- + +# PyQuest — sandbox di test + +Pagina temporanea per validare **motore + renderer + player** di PyQuest. Non è +linkata dalla sidebar (`unlisted: true`): la raggiungi solo via URL +`/docs/pyquest-test`. Il componente è giocabile end-to-end: editor + Esegui + +board animata passo per passo. I livelli qui sono quelli del mondo pilota +`mondo-01`; la galleria vera vive su `/pyquest`. + +## 1. Livello dal plugin dati (world/level) + +Livello `primo-passo` del mondo `mondo-01`, risolto dai global data del plugin +`pyquest`. Il codice iniziale manda Byte dritto contro il muro di destra: aggiungi +`turn_right()` e due `move()` per raggiungere la bandiera. + + + +## 2. Navigazione con i sensori + +Livello `svolta-al-buio`: un corridoio a L. Fai arrivare Byte sulla bandiera con +un ciclo `while not on_goal()` che gira quando `is_blocked()`. + + + +## 2-bis. Combat, raccolta, hint e stelle + +Livello `il-tesoro-e-la-guardia`: un bug **melee** ti viene incontro. Raccogli +prima la gemma con `collect()`, poi colpiscilo con `attack()` (ha 2 PV). La +vittoria richiede *sia* raccogliere la gemma *sia* sconfiggere il bug (`win` +multipla). Alla vittoria compaiono le stelle (da `par`); i suggerimenti si +rivelano a scaglioni sotto l'editor; «Spiegamelo facile» copia il prompt negli +appunti. + + + +## 3. Indipendenza tra istanze + +Due istanze dello stesso livello sulla stessa pagina: devono avere trace e player +indipendenti (esegui l'una, l'altra resta ferma). + + + +## 4. Id ignoto + +Un `level` inesistente non deve ripiegare su un livello di riserva: atteso un +pannello d'errore esplicito. + + + +## Note per la verifica (checklist finale) + +- **a)** Livello 1 (`primo-passo`): scrivi la soluzione `move, move, move, + turn_right, move, move` → autoplay da passo 0 e, a fine animazione, il pannello + di vittoria col flavor text di Byte e 3 stelle (6 azioni, par 6). +- **b)** Un errore nel codice (es. `boom` a riga 2) → trace parziale animata, + poi pannello «C'è un errore nel codice» col traceback (riga giusta). +- **c)** `while True: move()` → pannello step limit nel tono di Byte; il + traceback resta in console. +- **d)** Slider di seek, play/pausa, passo avanti/indietro, reset; le 4 + velocità (0,5× 1× 2× 4×) scalano anche la durata delle transizioni. +- **e)** `print()` interleaved compaiono in console solo quando l'animazione + raggiunge il passo in cui sono stati emessi. +- **f)** Livello 1: scrivi cinque `move()` di fila → Byte arriva al muro e ci + sbatte due volte senza vincere → flavor «bump ripetuto». +- **g)** Dark mode: board, sprite, stelle e pannelli esito leggibili in entrambi + i temi; la griglia scala col contenitore (`--pq-cell`). +- **h)** Livello `il-tesoro-e-la-guardia`: il bug melee si avvicina a ogni + azione; con la soluzione (`move, move, collect, move, attack, attack`) la + vittoria arriva a `par` azioni → 3 stelle. La trace è **identica** su run + ripetuti. Ricaricando la pagina, il livello risulta «Completato · record N». +- **i)** Morte dell'eroe (es. resta fermo accanto a un nemico che lo colpisce + fino a 0 PV): UI di sconfitta (non pannello errore); traceback breve in + console. +- **j)** Suggerimenti: si rivelano uno alla volta (1/3 → 2/3 → 3/3) e non + persistono al reload. «Spiegamelo facile» copia un prompt con codice + titolo + del livello. +- **k)** Galleria `/pyquest`: lucchetti che si aprono al completamento, stelle + per livello, e da `/pyquest/gioca` la navigazione «Livello successivo» dopo la + vittoria. diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 4e79104..5d6a1a9 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -127,6 +127,7 @@ export default async function createConfig(): Promise { plugins: [ './plugins/pyrunner/index.js', + './plugins/pyquest/index.js', './plugins/exercise-graph/index.js', './plugins/copy-page-md/index.js', './plugins/curriculum/index.js', @@ -256,6 +257,11 @@ export default async function createConfig(): Promise { }, ], }, + { + to: '/pyquest', + label: 'PyQuest', + position: 'left', + }, /* { to: '/blog', label: 'Blog', diff --git a/package.json b/package.json index 87107fb..e3132f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "python-doesnt-byte", - "version": "0.13.1", + "version": "0.14.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/plugins/pyquest/index.js b/plugins/pyquest/index.js new file mode 100644 index 0000000..9b0b007 --- /dev/null +++ b/plugins/pyquest/index.js @@ -0,0 +1,306 @@ +const path = require('path'); +const fs = require('fs'); + +const PLUGIN_NAME = 'pyquest'; + +const DEFAULT_OPTIONS = { + // I mondi vivono in static/pyquest//world.json (uno per cartella). + worldsDir: 'pyquest', +}; + +// I facing validi (specular a DIRS in static/bry-libs/pyquest.py). +const FACINGS = ['north', 'east', 'south', 'west']; +const ENEMY_BEHAVIORS = ['static', 'melee']; +const WIN_TYPES = ['reach', 'collect', 'defeat']; +// Versioni di schema che questo plugin sa leggere (spec D8). +const KNOWN_SCHEMA_VERSIONS = [1]; + +// --- Validazione build-time --------------------------------------------------- +// +// Fallire il build (throw) su dati malformati è coerente con `onBrokenLinks: +// 'throw'`: meglio un mondo rotto che ferma la CI di un livello che esplode nel +// browser dello studente. + +class WorldError extends Error {} + +/** + * Guardia quoting (spec step 7): i campi stringa che finiscono nel modello a + * runtime vengono iniettati in Python dentro una stringa triple-quoted + * (`''''''` in runLevel.ts). Un apice, una virgoletta o un backslash in + * quei campi potrebbero rompere quella stringa o iniettare codice. Li vietiamo + * a monte: sono identificatori corti d'autore (facing, kind, id, legenda, + * righe mappa), non prosa — nessuna ragione legittima per avere `'"\`. + */ +function assertSafeString(value, where) { + if (typeof value !== 'string') { + throw new WorldError(`${where}: atteso una stringa, trovato ${typeof value}.`); + } + if (/['"\\]/.test(value)) { + throw new WorldError( + `${where}: la stringa "${value}" contiene un carattere vietato (' " \\). ` + + 'Questi campi vengono iniettati nel motore Python: usa solo identificatori semplici.', + ); + } + // Niente caratteri di controllo (newline compresi): romperebbero il JSON su + // una riga o la stringa Python. + if (/[\x00-\x1f]/.test(value)) { // eslint-disable-line no-control-regex + throw new WorldError(`${where}: la stringa contiene caratteri di controllo non ammessi.`); + } +} + +function assertInt(value, where) { + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new WorldError(`${where}: atteso un intero, trovato ${JSON.stringify(value)}.`); + } +} + +function assertPoint(pt, where) { + if (!pt || typeof pt !== 'object') { + throw new WorldError(`${where}: atteso un punto {x, y}.`); + } + assertInt(pt.x, `${where}.x`); + assertInt(pt.y, `${where}.y`); +} + +/** + * Geometria della griglia già validata: dimensioni + celle muro, per i check + * di posizione (entità dentro i bordi e mai su una cella `wall`). + */ +function gridGeometry(grid) { + const walls = new Set(); + grid.rows.forEach((row, y) => { + Array.from(row).forEach((ch, x) => { + if (grid.legend[ch] === 'wall') walls.add(`${x},${y}`); + }); + }); + return { width: grid.rows[0].length, height: grid.rows.length, walls }; +} + +function assertOnFloor(pt, geo, where) { + if (pt.x < 0 || pt.y < 0 || pt.x >= geo.width || pt.y >= geo.height) { + throw new WorldError( + `${where}: (${pt.x}, ${pt.y}) è fuori dalla griglia ${geo.width}×${geo.height}.`, + ); + } + if (geo.walls.has(`${pt.x},${pt.y}`)) { + throw new WorldError(`${where}: (${pt.x}, ${pt.y}) è su una cella muro.`); + } +} + +function validateGrid(grid, where) { + if (!grid || typeof grid !== 'object') { + throw new WorldError(`${where}: manca la griglia.`); + } + const { legend, rows } = grid; + if (!legend || typeof legend !== 'object') { + throw new WorldError(`${where}.legend: attesa una mappa carattere→ruolo.`); + } + for (const [ch, role] of Object.entries(legend)) { + assertSafeString(ch, `${where}.legend (chiave)`); + assertSafeString(role, `${where}.legend['${ch}']`); + } + if (!Array.isArray(rows) || rows.length === 0) { + throw new WorldError(`${where}.rows: atteso un array non vuoto di righe.`); + } + const width = rows[0].length; + rows.forEach((row, i) => { + assertSafeString(row, `${where}.rows[${i}]`); + if (row.length !== width) { + throw new WorldError( + `${where}.rows[${i}]: larghezza ${row.length} ≠ ${width} (griglia non rettangolare).`, + ); + } + for (const ch of row) { + if (!(ch in legend)) { + throw new WorldError( + `${where}.rows[${i}]: il carattere "${ch}" non è nella legenda.`, + ); + } + } + }); +} + +function validateLevel(level, where) { + if (!level || typeof level !== 'object') { + throw new WorldError(`${where}: livello non valido.`); + } + assertSafeString(level.id, `${where}.id`); + if (typeof level.title !== 'string' || !level.title) { + throw new WorldError(`${where}.title: titolo mancante.`); + } + + validateGrid(level.grid, `${where}.grid`); + const geo = gridGeometry(level.grid); + + const { hero } = level; + assertPoint(hero, `${where}.hero`); + assertOnFloor(hero, geo, `${where}.hero`); + if (hero.facing !== undefined && !FACINGS.includes(hero.facing)) { + throw new WorldError(`${where}.hero.facing: "${hero.facing}" non è un facing valido.`); + } + if (hero.hp !== undefined) assertInt(hero.hp, `${where}.hero.hp`); + + if (level.goal !== undefined) { + assertPoint(level.goal, `${where}.goal`); + assertOnFloor(level.goal, geo, `${where}.goal`); + } + + const ids = new Set(); + for (const [i, e] of (level.enemies ?? []).entries()) { + const w = `${where}.enemies[${i}]`; + assertSafeString(e.id, `${w}.id`); + if (ids.has(e.id)) throw new WorldError(`${w}.id: "${e.id}" duplicato.`); + ids.add(e.id); + assertSafeString(e.kind, `${w}.kind`); + assertPoint(e, w); + assertOnFloor(e, geo, w); + assertInt(e.hp, `${w}.hp`); + if (!ENEMY_BEHAVIORS.includes(e.behavior)) { + throw new WorldError(`${w}.behavior: "${e.behavior}" non valido (static|melee).`); + } + } + for (const [i, r] of (level.resources ?? []).entries()) { + const w = `${where}.resources[${i}]`; + assertSafeString(r.id, `${w}.id`); + if (ids.has(r.id)) throw new WorldError(`${w}.id: "${r.id}" duplicato.`); + ids.add(r.id); + assertSafeString(r.kind, `${w}.kind`); + assertPoint(r, w); + assertOnFloor(r, geo, w); + } + + if (!Array.isArray(level.win) || level.win.length === 0) { + throw new WorldError(`${where}.win: attesa almeno una condizione di vittoria.`); + } + for (const [i, cond] of level.win.entries()) { + const w = `${where}.win[${i}]`; + if (!cond || !WIN_TYPES.includes(cond.type)) { + throw new WorldError(`${w}.type: atteso uno di ${WIN_TYPES.join('|')}.`); + } + if (cond.type === 'reach' && level.goal === undefined) { + throw new WorldError(`${w}: vittoria "reach" ma il livello non ha un goal.`); + } + if (cond.type === 'collect') { + assertSafeString(cond.kind, `${w}.kind`); + assertInt(cond.qty, `${w}.qty`); + } + if (cond.type === 'defeat') { + if (cond.kind !== undefined) assertSafeString(cond.kind, `${w}.kind`); + if (cond.count !== undefined) assertInt(cond.count, `${w}.count`); + } + } + + if (typeof level.starterCode !== 'string') { + throw new WorldError(`${where}.starterCode: codice iniziale mancante.`); + } + if ( + !Array.isArray(level.hints) || + level.hints.length < 1 || + level.hints.length > 3 || + level.hints.some((h) => typeof h !== 'string' || !h) + ) { + throw new WorldError(`${where}.hints: attesi da 1 a 3 suggerimenti (stringhe non vuote).`); + } + if (level.maxSteps !== undefined) assertInt(level.maxSteps, `${where}.maxSteps`); + assertInt(level.par, `${where}.par`); + if (level.par < 1) { + throw new WorldError(`${where}.par: atteso ≥ 1, trovato ${level.par}.`); + } +} + +function validateWorld(world, file) { + if (!world || typeof world !== 'object') { + throw new WorldError(`${file}: JSON del mondo non valido.`); + } + if (!KNOWN_SCHEMA_VERSIONS.includes(world.schemaVersion)) { + throw new WorldError( + `${file} → schemaVersion: ${JSON.stringify(world.schemaVersion)} non riconosciuta ` + + `(note: ${KNOWN_SCHEMA_VERSIONS.join(', ')}).`, + ); + } + assertSafeString(world.id, `${file} → id`); + if (!Array.isArray(world.levels) || world.levels.length === 0) { + throw new WorldError(`${file}: il mondo non ha livelli.`); + } + const levelIds = new Set(); + world.levels.forEach((lvl, i) => { + validateLevel(lvl, `${file} → levels[${i}] (${lvl && lvl.id})`); + if (levelIds.has(lvl.id)) { + throw new WorldError(`${file}: id livello "${lvl.id}" duplicato.`); + } + levelIds.add(lvl.id); + }); +} + +function loadWorlds(worldsAbsDir) { + const worlds = {}; + if (!fs.existsSync(worldsAbsDir)) { + console.warn( + `[pyquest] Directory mondi non trovata: ${worldsAbsDir} — nessun mondo caricato.`, + ); + return worlds; + } + for (const entry of fs.readdirSync(worldsAbsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const file = path.join(worldsAbsDir, entry.name, 'world.json'); + if (!fs.existsSync(file)) continue; + let world; + try { + world = JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch (err) { + throw new WorldError(`${file}: JSON illeggibile — ${err.message}`); + } + validateWorld(world, file); + if (world.id in worlds) { + throw new WorldError(`[pyquest] id mondo "${world.id}" duplicato (${file}).`); + } + worlds[world.id] = world; + } + return worlds; +} + +/** + * I global data di Docusaurus finiscono nel bundle principale, servito a ogni + * pagina del sito. `solution` serve solo all'autore (calibrare il par, provare + * il livello nell'editor): il client non la legge mai, quindi la togliamo — + * meno peso su ogni pagina e la risposta non arriva in faccia allo studente + * insieme al livello. Resta nel world.json su disco, che è servito come file + * statico: chi la cerca la trova, non è una protezione. + */ +function stripAuthorFields(world) { + return { + ...world, + levels: world.levels.map(({ solution: _solution, ...level }) => level), + }; +} + +module.exports = function pyquestPlugin(context, pluginOptions = {}) { + const opts = { ...DEFAULT_OPTIONS, ...pluginOptions }; + const staticRoot = + (context.siteConfig.staticDirectories && + context.siteConfig.staticDirectories[0]) || + 'static'; + const worldsAbsDir = path.join(context.siteDir, staticRoot, opts.worldsDir); + + return { + name: PLUGIN_NAME, + + async loadContent() { + return { worlds: loadWorlds(worldsAbsDir) }; + }, + + async contentLoaded({ content, actions }) { + const worlds = {}; + for (const [id, world] of Object.entries(content.worlds)) { + worlds[id] = stripAuthorFields(world); + } + actions.setGlobalData({ worlds }); + }, + + getPathsToWatch() { + return [path.join(worldsAbsDir, '**/world.json')]; + }, + }; +}; + +module.exports.PLUGIN_NAME = PLUGIN_NAME; diff --git a/src/components/PyQuest/GamePlayer.module.css b/src/components/PyQuest/GamePlayer.module.css new file mode 100644 index 0000000..c3f9279 --- /dev/null +++ b/src/components/PyQuest/GamePlayer.module.css @@ -0,0 +1,151 @@ +/* GamePlayer — board + slider di seek + barra dei comandi + console. */ + +.player { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* --- Slider di seek ------------------------------------------------------ */ + +.scrub { + display: flex; + align-items: center; + gap: 10px; +} + +.scrubInput { + flex: 1; + min-width: 0; + accent-color: var(--at-accent, #0270ad); + cursor: pointer; +} + +.scrubInput:disabled { + cursor: default; + opacity: 0.4; +} + +.counter { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8rem; + opacity: 0.7; + min-width: 4.5em; + text-align: right; +} + +/* --- Transport ----------------------------------------------------------- */ + +.controls { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: var(--radius-xs, 6px); + border: 1px solid var(--at-border, rgba(0, 0, 0, 0.08)); + background: var(--at-bg-panel, rgba(255, 255, 255, 0.7)); + color: inherit; + cursor: pointer; + transition: + background var(--dur-fast, 150ms) var(--ease-out), + transform var(--dur-fast, 150ms) var(--ease-out); +} + +.btn:hover:not(:disabled) { + background: var(--at-bg-chip, rgba(0, 0, 0, 0.04)); +} + +.btn:active:not(:disabled) { + transform: scale(0.98); +} + +.btn:disabled { + opacity: 0.4; + cursor: default; +} + +.playBtn { + color: #fff; + background: var(--at-accent, #0270ad); + border-color: transparent; +} + +.playBtn:hover:not(:disabled) { + background: var(--at-accent-soft, #0369a1); +} + +.speed { + display: inline-flex; + margin-left: auto; + border: 1px solid var(--at-border, rgba(0, 0, 0, 0.08)); + border-radius: var(--radius-pill, 999px); + overflow: hidden; +} + +.speedBtn { + border: none; + background: transparent; + color: inherit; + font-size: 0.75rem; + padding: 5px 10px; + cursor: pointer; + transition: background var(--dur-fast, 150ms) var(--ease-out); +} + +.speedBtn:hover { + background: var(--at-bg-chip, rgba(0, 0, 0, 0.04)); +} + +.speedActive { + background: var(--at-accent, #0270ad); + color: #fff; +} + +.speedActive:hover { + background: var(--at-accent, #0270ad); +} + +/* Su touch i target del transport salgono a 44px (il dito non ha la precisione + del mouse); il desktop resta compatto come nel player di Algorithm. */ +@media (pointer: coarse) { + .btn { + width: 44px; + height: 44px; + } + + .speedBtn { + min-height: 44px; + padding: 5px 14px; + } + + .scrubInput { + height: 32px; + } +} + +/* --- Console sincronizzata (print/traceback per atStep) ------------------- */ + +.console { + margin: 0; + padding: 10px 12px; + border-radius: var(--radius-sm, 10px); + background: var(--at-bg-subtle, rgba(0, 0, 0, 0.025)); + border: 1px solid var(--at-border, rgba(0, 0, 0, 0.08)); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8rem; + white-space: pre-wrap; + max-height: 200px; + overflow: auto; +} + +.stderr { + color: var(--ifm-color-danger, #d6336c); +} diff --git a/src/components/PyQuest/GamePlayer.tsx b/src/components/PyQuest/GamePlayer.tsx new file mode 100644 index 0000000..416fd63 --- /dev/null +++ b/src/components/PyQuest/GamePlayer.tsx @@ -0,0 +1,257 @@ +/** + * GamePlayer — transport della trace PyQuest (riferimento diretto: + * src/components/Algorithm/Player.tsx, reducer e azioni identici). + * + * Riceve la trace completa di eventi (il motore è sincrono: arriva in ms) e + * deriva la scena al passo corrente con slice + reduce. Differenza voluta da + * Algorithm: al termine del run parte l'autoplay da passo 0 (lo studente ha + * premuto Esegui: vuole vedere il film) — il genitore lo segnala incrementando + * `playKey`. + * + * La durata delle transizioni della scena è iniettata come `--pq-dur` + * (DUR_MS[speedIdx]); la console mostra solo le righe di log con + * `atStep <= passo corrente` (sincronizzazione print/traceback ↔ animazione). + */ + +import { + useEffect, + useMemo, + useReducer, + useState, + type CSSProperties, +} from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faPlay, + faPause, + faForwardStep, + faBackwardStep, + faRotateLeft, +} from '@fortawesome/free-solid-svg-icons'; +import clsx from 'clsx'; +import GameScene from './GameScene'; +import { applyGameEvent, initScene } from './applyEvent'; +import type { GameEvent, LevelDef, LogLine } from './types'; +import styles from './GamePlayer.module.css'; + +const SPEEDS = [0.5, 1, 2, 4]; +const TICK_MS = [700, 400, 220, 120]; +const DUR_MS = [520, 320, 180, 100]; +const DEFAULT_SPEED_IDX = 1; + +interface PlayerState { + stepIndex: number; + playing: boolean; + speedIdx: number; +} + +type Action = + | { type: 'FWD' } + | { type: 'BACK' } + | { type: 'RESET' } + | { type: 'TOGGLE_PLAY' } + | { type: 'STOP' } + | { type: 'SEEK'; to: number } + | { type: 'SET_SPEED'; idx: number }; + +export interface GamePlayerProps { + level: LevelDef; + character: string; + events: GameEvent[]; + logs: LogLine[]; + /** Incrementato dal genitore a ogni run: riparte da passo 0 in autoplay. */ + playKey: number; + /** Notifica il passo corrente (il genitore vi deriva la fine animazione). */ + onStepChange?: (step: number, total: number) => void; +} + +export default function GamePlayer({ + level, + character, + events, + logs, + playKey, + onStepChange, +}: GamePlayerProps) { + const total = events.length; + + const [state, dispatch] = useReducer( + (s: PlayerState, action: Action): PlayerState => { + switch (action.type) { + case 'FWD': { + const ni = Math.min(s.stepIndex + 1, total); + return { + ...s, + stepIndex: ni, + playing: ni >= total ? false : s.playing, + }; + } + case 'BACK': + return { + ...s, + stepIndex: Math.max(s.stepIndex - 1, 0), + playing: false, + }; + case 'RESET': + return { ...s, stepIndex: 0, playing: false }; + case 'TOGGLE_PLAY': + if (s.stepIndex >= total) + return { ...s, stepIndex: 0, playing: true }; + return { ...s, playing: !s.playing }; + case 'STOP': + return { ...s, playing: false }; + case 'SEEK': + return { ...s, stepIndex: action.to, playing: false }; + case 'SET_SPEED': + return { ...s, speedIdx: action.idx }; + default: + return s; + } + }, + { stepIndex: 0, playing: false, speedIdx: DEFAULT_SPEED_IDX }, + ); + + // Nuovo run: autoplay dal passo 0 (velocità conservata). Il reset avviene IN + // RENDER (pattern «adjust state during render»), non in un effect: con + // l'effect, al re-run il cursore stantio del run precedente (già a fine + // trace) veniva notificato via onStepChange prima del reset e il genitore + // chiudeva subito l'animazione (pannello esito visibile per tutto il replay). + const [prevPlayKey, setPrevPlayKey] = useState(playKey); + if (prevPlayKey !== playKey) { + setPrevPlayKey(playKey); + dispatch({ type: 'RESET' }); + dispatch({ type: 'TOGGLE_PLAY' }); + } + + // Autoplay. + useEffect(() => { + if (!state.playing) return undefined; + const id = setTimeout( + () => dispatch({ type: 'FWD' }), + TICK_MS[state.speedIdx], + ); + return () => clearTimeout(id); + }, [state.playing, state.stepIndex, state.speedIdx]); + + // Trace nuova più corta del cursore (run rieseguito): clamp in render. + const stepIndex = Math.min(state.stepIndex, total); + + const scene = useMemo( + () => events.slice(0, stepIndex).reduce(applyGameEvent, initScene(level)), + [events, stepIndex, level], + ); + + useEffect(() => { + onStepChange?.(stepIndex, total); + }, [stepIndex, total, onStepChange]); + + const atStart = stepIndex === 0; + const atEnd = stepIndex >= total; + const visibleLogs = logs.filter((l) => l.atStep <= stepIndex); + + const durStyle = { + '--pq-dur': `${DUR_MS[state.speedIdx]}ms`, + } as CSSProperties; + + return ( +
+ + +
+ + dispatch({ type: 'SEEK', to: Number(e.target.value) }) + } + /> + + {stepIndex} / {total} + +
+ +
+ + + + + +
+ {SPEEDS.map((sp, i) => ( + + ))} +
+
+ + {visibleLogs.length > 0 && ( +
+          {visibleLogs.map((l, i) => (
+            
+              {l.text}
+            
+          ))}
+        
+ )} +
+ ); +} diff --git a/src/components/PyQuest/GameScene.module.css b/src/components/PyQuest/GameScene.module.css new file mode 100644 index 0000000..23f2fba --- /dev/null +++ b/src/components/PyQuest/GameScene.module.css @@ -0,0 +1,353 @@ +/* GameScene — board a griglia + entità posizionate per trasformazione. + Le entità animano il movimento via transition su `transform`; la durata è + `--pq-dur`, iniettata dal GamePlayer in base alla velocità scelta. Gli + effetti one-shot (fx) sono keyframes rimontate a ogni passo (key cangiante). + `--pq-cell` è calcolata dal renderer (ResizeObserver): min(48px, larghezza + contenitore / colonne). */ + +.scene { + /* Tema di scena (light). Override dark in fondo, con selettore + html[data-theme='dark'] per evitare il gotcha oklch/P3 su display wide. */ + --pq-board-bg: #e9edf2; + --pq-floor: #f5f7fa; + --pq-wall: #40495b; + --pq-grid-line: rgba(0, 0, 0, 0.06); + --pq-hero: var(--at-accent, #0270ad); + --pq-hero-track: #3d4759; + --pq-hero-panel: rgba(255, 255, 255, 0.35); + --pq-hero-led-ring: #ffffff; + --pq-hero-led: #22b8cf; + --pq-monty: #2f9e44; + --pq-monty-hat: #8d6e3f; + --pq-monty-eye: #1a1a1a; + --pq-enemy: #d6336c; + --pq-resource: #f08c00; + --pq-goal: #2f9e44; + + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; + width: 100%; +} + +.board { + position: relative; + width: calc(var(--pq-cols) * var(--pq-cell)); + height: calc(var(--pq-rows) * var(--pq-cell)); + max-width: 100%; + background: var(--pq-board-bg); + border-radius: var(--radius-sm, 10px); + border: 1px solid var(--at-border, rgba(0, 0, 0, 0.08)); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +/* Base comune a celle ed entità: box grande una cella, posizionato per + trasformazione tramite le custom property --pq-x/--pq-y. */ +.floor, +.wall, +.goal, +.resource, +.enemy, +.hero, +.fx { + position: absolute; + top: 0; + left: 0; + width: var(--pq-cell); + height: var(--pq-cell); + transform: translate( + calc(var(--pq-x) * var(--pq-cell)), + calc(var(--pq-y) * var(--pq-cell)) + ); +} + +/* --- Celle -------------------------------------------------------------- */ + +.floor, +.wall { + padding: 1px; + background-clip: content-box; + box-shadow: inset 0 0 0 1px var(--pq-grid-line); +} + +.floor { + background-color: var(--pq-floor); +} + +.wall { + background-color: var(--pq-wall); +} + +/* --- Entità ------------------------------------------------------------- */ + +.goal, +.resource, +.enemy, +.hero { + display: flex; + align-items: center; + justify-content: center; + font-size: calc(var(--pq-cell) * 0.5); + pointer-events: none; +} + +.goal { + color: var(--pq-goal); + font-size: calc(var(--pq-cell) * 0.42); + opacity: 0.85; +} + +.resource { + animation: pq-float 2.4s var(--ease-smooth, ease-in-out) infinite; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25)); + transition: + opacity var(--pq-dur, 320ms) var(--ease-out), + scale var(--pq-dur, 320ms) var(--ease-out); +} + +/* Raccolta: resta montata (identità DOM stabile), svanisce e basta. */ +.collected { + opacity: 0; + scale: 0.3; + animation: none; +} + +.enemy { + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25)); + transition: + transform var(--pq-dur, 320ms) var(--ease-smooth), + opacity var(--pq-dur, 320ms) var(--ease-out), + scale var(--pq-dur, 320ms) var(--ease-out); +} + +/* Sconfitto: resta montato, dissolvenza. */ +.dead { + opacity: 0; + scale: 0.4; +} + +/* Pips HP del nemico (un puntino per PV). */ +.enemyPips { + position: absolute; + top: 3px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 2px; +} + +.pip { + width: 4px; + height: 4px; + border-radius: var(--radius-circle, 50%); + background: var(--pq-enemy); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.65); +} + +/* L'eroe anima lo spostamento tra celle. */ +.hero { + transition: transform var(--pq-dur, 320ms) var(--ease-smooth); + z-index: 2; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)); +} + +/* Wrapper degli effetti one-shot sull'eroe: rimontato (key cangiante) a ogni + fx, così la keyframe riparte anche su bump consecutivi. */ +.heroFxWrap { + display: block; + width: 100%; + height: 100%; + padding: 12%; +} + +/* Lo sprite ruota col facing (disegnato rivolto a nord). */ +.sprite { + display: block; + width: 100%; + height: 100%; + transition: transform var(--pq-dur, 320ms) var(--ease-smooth); +} + +/* --- Effetti one-shot --------------------------------------------------- */ + +.fx { + z-index: 3; + pointer-events: none; +} + +.fx_attack::after, +.fx_defeat::after, +.fx_collect::after { + content: ''; + position: absolute; + inset: 15%; + border-radius: var(--radius-circle, 50%); +} + +.fx_attack::after { + border: 2px solid var(--pq-enemy); + animation: pq-ring var(--pq-dur, 320ms) var(--ease-out) forwards; +} + +.fx_collect::after { + background: radial-gradient(circle, var(--pq-resource) 0%, transparent 70%); + animation: pq-pop var(--pq-dur, 320ms) var(--ease-spring) forwards; +} + +.fx_defeat::after { + border: 2px solid var(--pq-enemy); + animation: pq-pop var(--pq-dur, 320ms) var(--ease-out) forwards; +} + +/* Effetti applicati direttamente sull'eroe. */ +.bump { + animation: pq-shake var(--pq-dur, 320ms) var(--ease-out); +} + +.heroHit { + animation: pq-flash var(--pq-dur, 320ms) var(--ease-out); +} + +@keyframes pq-float { + 0%, + 100% { + margin-top: 0; + } + 50% { + margin-top: -3px; + } +} + +@keyframes pq-shake { + 0%, + 100% { + translate: 0; + } + 25% { + translate: -4px 0; + } + 75% { + translate: 4px 0; + } +} + +@keyframes pq-flash { + 0%, + 100% { + filter: none; + } + 50% { + filter: drop-shadow(0 0 6px var(--pq-enemy)) brightness(1.4); + } +} + +@keyframes pq-ring { + from { + transform: scale(0.4); + opacity: 0.9; + } + to { + transform: scale(1.3); + opacity: 0; + } +} + +@keyframes pq-pop { + from { + transform: scale(0.2); + opacity: 0.9; + } + to { + transform: scale(1.2); + opacity: 0; + } +} + +/* --- HUD ---------------------------------------------------------------- */ + +.hud { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + min-height: 1.6rem; +} + +.hp { + display: inline-flex; + gap: 3px; + color: #e03131; +} + +.hpPip { + font-size: 0.95rem; +} + +.inventory { + display: inline-flex; + gap: 6px; +} + +.invChip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + padding: 2px 8px; + border-radius: var(--radius-pill, 999px); + background: var(--at-bg-chip, rgba(0, 0, 0, 0.04)); + color: var(--pq-resource); +} + +.badge { + font-size: 0.82rem; + font-weight: 700; + padding: 3px 10px; + border-radius: var(--radius-pill, 999px); +} + +.badgeWin { + color: #fff; + background: var(--pq-goal); +} + +.badgeFail { + color: #fff; + background: var(--pq-enemy); +} + +/* --- Dark: override del tema di scena (html[data-theme] contro gotcha P3) - */ + +:global(html[data-theme='dark']) .scene { + --pq-board-bg: #14161c; + /* Muro e pavimento devono restare distinguibili a colpo d'occhio: il primo + tentativo (#1e2230 su #2b3245) stava a 1,3:1 e la mappa spariva. Qui il + rapporto è ~3:1, con il muro chiaro sul pavimento scuro — l'inverso del + tema light, dove il muro è il colore pieno. */ + --pq-floor: #151926; + --pq-wall: #59637f; + --pq-grid-line: rgba(255, 255, 255, 0.07); + --pq-hero: var(--at-accent, #38bdf8); + --pq-hero-track: #566178; + --pq-hero-panel: rgba(0, 0, 0, 0.25); + --pq-hero-led-ring: #10131a; + --pq-hero-led: #3bc9db; + --pq-monty: #51cf66; + --pq-monty-hat: #a98953; + --pq-monty-eye: #10131a; + --pq-enemy: #f06595; + --pq-resource: #ffc078; + --pq-goal: #51cf66; +} + +@media (prefers-reduced-motion: reduce) { + .hero, + .enemy, + .resource, + .sprite { + transition: none; + animation: none; + } +} diff --git a/src/components/PyQuest/GameScene.tsx b/src/components/PyQuest/GameScene.tsx new file mode 100644 index 0000000..d980a84 --- /dev/null +++ b/src/components/PyQuest/GameScene.tsx @@ -0,0 +1,239 @@ +/** + * GameScene — renderer DOM+CSS di uno stato di scena PyQuest (spec D5). + * + * Puro e derivato: riceve un `SceneState` (già ridotto dalla trace da + * applyEvent) e lo disegna. Nessuna logica di gioco, nessun timer: le + * animazioni sono transizioni CSS sulle posizioni (durata `--pq-dur`, iniettata + * dal player in base alla velocità) più effetti one-shot legati a `scene.fx`. + * + * Identità DOM stabile: nemici morti e risorse raccolte NON vengono smontati — + * restano nel DOM con le classi `dead`/`collected` (dissolvenza CSS), così le + * transizioni e lo scrubbing all'indietro funzionano senza remount. + * + * `fxKey` cambia a ogni passo del player: è la `key` degli elementi effetto, + * così due fx consecutivi dello stesso tipo rimontano il nodo e la keyframe + * riparte. + */ + +import { useEffect, useRef, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import clsx from 'clsx'; +import type { CSSProperties } from 'react'; +import { + characterDef, + enemyVisual, + resourceVisual, + GOAL_ICON, + HP_ICON, +} from './characters'; +import type { Facing, LevelDef, SceneState } from './types'; +import styles from './GameScene.module.css'; + +/** Lato massimo di una cella (px); sotto, la griglia scala col contenitore. */ +const MAX_CELL_PX = 48; + +// Gradi di rotazione dello sprite per facing (disegnato rivolto a nord). +// Esportato: anche l'editor livelli orienta l'eroe con la stessa tabella. +export const FACING_DEG: Record = { + north: 0, + east: 90, + south: 180, + west: 270, +}; + +interface CellStyle extends CSSProperties { + '--pq-x'?: number; + '--pq-y'?: number; +} + +function cellVars(x: number, y: number): CellStyle { + return { '--pq-x': x, '--pq-y': y }; +} + +export interface GameSceneProps { + level: LevelDef; + character: string; + scene: SceneState; + /** Cambia a ogni passo: fa ripartire le animazioni one-shot. */ + fxKey: number; +} + +export default function GameScene({ + level, + character, + scene, + fxKey, +}: GameSceneProps) { + const rows = level.grid.rows; + const height = rows.length; + const width = rows.length > 0 ? rows[0].length : 0; + const legend = level.grid.legend; + const char = characterDef(character); + + // --pq-cell = min(48px, larghezza contenitore / colonne) via ResizeObserver. + const wrapRef = useRef(null); + const [cellPx, setCellPx] = useState(MAX_CELL_PX); + useEffect(() => { + const el = wrapRef.current; + if (!el || typeof ResizeObserver === 'undefined') return undefined; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width ?? 0; + if (w > 0 && width > 0) { + setCellPx(Math.min(MAX_CELL_PX, Math.floor(w / width))); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, [width]); + + const boardStyle = { + '--pq-cols': width, + '--pq-rows': height, + '--pq-cell': `${cellPx}px`, + } as CSSProperties as CellStyle; + + const heroFx = + scene.fx?.kind === 'bump' || scene.fx?.kind === 'hero_hit' + ? scene.fx.kind + : null; + + return ( +
+
+ {/* Griglia di celle (pavimento/muro). */} + {rows.map((row, y) => + Array.from(row).map((ch, x) => { + const wall = legend[ch] === 'wall'; + return ( +
+ ); + }), + )} + + {/* Goal (sotto le entità: l'eroe ci sale sopra). */} + {level.goal && ( +
+ +
+ )} + + {/* Risorse: sempre montate; da raccolte restano con la classe + `collected` (dissolvenza), mai smontate. */} + {Object.entries(scene.resources).map(([id, r]) => { + const v = resourceVisual(r.kind); + return ( +
+ +
+ ); + })} + + {/* Nemici: sempre montati; da morti restano con la classe `dead`. */} + {Object.entries(scene.enemies).map(([id, e]) => { + const v = enemyVisual(e.kind); + return ( +
+ + {e.alive && e.hp > 0 && ( +
+ ); + })} + + {/* Eroe: il div esterno anima la posizione (transition su transform), + il wrapper interno porta gli effetti one-shot (key cangiante), + lo sprite interno ruota col facing. */} +
+ + + {char.sprite} + + +
+ + {/* Effetto one-shot posizionale (attacco/raccolta/sconfitta). */} + {scene.fx && scene.fx.at && !heroFx && ( +
+ )} +
+ + {/* HUD di scena: PV, inventario, esito (precedenza won > failed). */} +
+
+ {Array.from({ length: Math.max(scene.hero.hp, 0) }).map((_, i) => ( + + ))} +
+
+ {Object.entries(scene.inventory).map(([kind, n]) => { + const v = resourceVisual(kind); + return ( + + {n} + + ); + })} +
+ {scene.won ? ( + Vittoria! + ) : scene.dead ? ( + + {char.name} è caduto + + ) : scene.stepLimit ? ( + + Troppe mosse + + ) : null} +
+
+ ); +} diff --git a/src/components/PyQuest/HintPanel.tsx b/src/components/PyQuest/HintPanel.tsx new file mode 100644 index 0000000..441408d --- /dev/null +++ b/src/components/PyQuest/HintPanel.tsx @@ -0,0 +1,48 @@ +/** + * HintPanel — suggerimenti progressivi di un livello PyQuest (spec step 15). + * + * Rivela gli hint uno alla volta (concettuale → direzionale → quasi-soluzione). + * Lo stato è **in memoria** (non persistito): riaprendo la pagina si riparte da + * zero suggerimenti, così lo studente prova prima con la testa. + */ + +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faLightbulb } from '@fortawesome/free-solid-svg-icons'; +import styles from './PyQuest.module.css'; + +export interface HintPanelProps { + hints: string[]; +} + +export default function HintPanel({ hints }: HintPanelProps) { + const [revealed, setRevealed] = useState(0); + if (hints.length === 0) return null; + + const allRevealed = revealed >= hints.length; + + return ( +
+ {revealed > 0 && ( +
    + {hints.slice(0, revealed).map((hint, i) => ( +
  1. {hint}
  2. + ))} +
+ )} + {!allRevealed && ( + + )} +
+ ); +} diff --git a/src/components/PyQuest/PyQuest.module.css b/src/components/PyQuest/PyQuest.module.css new file mode 100644 index 0000000..43ec9a2 --- /dev/null +++ b/src/components/PyQuest/PyQuest.module.css @@ -0,0 +1,201 @@ +/* PyQuest — layout del componente giocabile: scena a sinistra, editor a destra + (impilati su schermi stretti). L'editor riusa toolbar e stili di PyRunner; + qui vivono solo il layout e i pannelli di esito (flavor text, D13). + Niente bordo-sinistra colorato: gli esiti si distinguono per sfondo. */ + +.root { + margin: 1.5rem 0; +} + +.layout { + display: grid; + grid-template-columns: minmax(0, auto) minmax(280px, 1fr); + gap: 20px; + align-items: start; +} + +@media (max-width: 820px) { + .layout { + grid-template-columns: 1fr; + } +} + +.stage { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.editorCol { + min-width: 0; +} + +/* --- Pannelli di esito ---------------------------------------------------- */ + +.panel { + padding: 12px 14px; + border-radius: var(--radius-sm, 10px); + border: 1px solid var(--at-border, rgba(0, 0, 0, 0.08)); + font-size: 0.92rem; + line-height: 1.5; +} + +.panelWin { + background: color-mix(in srgb, #2f9e44 12%, transparent); + border-color: color-mix(in srgb, #2f9e44 35%, transparent); +} + +.panelFail { + background: color-mix(in srgb, #d6336c 10%, transparent); + border-color: color-mix(in srgb, #d6336c 30%, transparent); +} + +.panelError { + background: color-mix(in srgb, #d6336c 6%, transparent); + border-color: color-mix(in srgb, #d6336c 25%, transparent); +} + +.panelNeutral { + background: var(--at-bg-subtle, rgba(0, 0, 0, 0.025)); +} + +.panelTitle { + margin: 0 0 6px; + font-weight: 700; +} + +.traceback { + margin: 0; + padding: 8px 10px; + border-radius: var(--radius-xs, 6px); + background: var(--at-bg-subtle, rgba(0, 0, 0, 0.04)); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.78rem; + white-space: pre-wrap; + max-height: 180px; + overflow: auto; +} + +.panelHint { + margin: 6px 0 0; + font-size: 0.82rem; + opacity: 0.75; +} + +.winHead { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.winSteps { + font-size: 0.86rem; + opacity: 0.85; +} + +.record { + color: #2f9e44; + font-weight: 700; +} + +.winFlavor { + margin: 0; +} + +/* --- Stelle (spec D11) ---------------------------------------------------- */ + +.stars { + display: inline-flex; + gap: 3px; + font-size: 1rem; + line-height: 1; +} + +.star { + color: var(--at-border, rgba(0, 0, 0, 0.18)); +} + +.starOn { + color: #f0a92b; +} + +html[data-theme='dark'] .star { + color: rgba(255, 255, 255, 0.22); +} + +html[data-theme='dark'] .starOn { + color: #ffca4b; +} + +/* --- Striscia «completato» (record persistito) --------------------------- */ + +.completed { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: var(--radius-sm, 10px); + background: color-mix(in srgb, #2f9e44 10%, transparent); + border: 1px solid color-mix(in srgb, #2f9e44 28%, transparent); + font-size: 0.86rem; + font-weight: 600; +} + +/* --- Suggerimenti progressivi (step 15) ---------------------------------- */ + +.hints { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} + +.hintList { + margin: 0; + padding-left: 1.2rem; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 0.9rem; + line-height: 1.5; +} + +.hintBtn { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + border-radius: var(--radius-pill, 999px); + border: 1px solid var(--at-border, rgba(0, 0, 0, 0.12)); + background: var(--at-bg-subtle, rgba(0, 0, 0, 0.03)); + color: inherit; + font: inherit; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: + background var(--dur-fast, 140ms) var(--ease-smooth, ease), + border-color var(--dur-fast, 140ms) var(--ease-smooth, ease), + transform var(--dur-fast, 140ms) var(--ease-smooth, ease); +} + +.hintBtn:hover { + background: color-mix(in srgb, #f0a92b 14%, transparent); + border-color: color-mix(in srgb, #f0a92b 45%, transparent); +} + +.hintBtn:active { + transform: scale(0.98); +} + +/* --- Errori di configurazione (props/plugin) ------------------------------ */ + +.error { + margin: 1.5rem 0; + padding: 12px; + border: 1px solid var(--at-border, rgba(0, 0, 0, 0.08)); + border-radius: var(--radius-sm, 10px); +} diff --git a/src/components/PyQuest/applyEvent.ts b/src/components/PyQuest/applyEvent.ts new file mode 100644 index 0000000..be7db87 --- /dev/null +++ b/src/components/PyQuest/applyEvent.ts @@ -0,0 +1,115 @@ +/** + * Riduttore di scena PyQuest (pattern Algorithm/applyStep.ts). + * + * Il motore Python emette una trace di `GameEvent` (via `runLevel`); la scena a + * un dato passo è derivata: `events.slice(0, step).reduce(applyGameEvent, + * initScene(level))`. Riduttore puro → seek, step-back e replay gratis. + * + * Il campo `fx` è **transiente**: descrive un'animazione one-shot (bump, colpo, + * raccolta…) valida solo per il passo che l'ha generata. Ogni `applyGameEvent` + * lo azzera e lo re-imposta solo se l'evento corrente lo richiede. + */ + +import type { GameEvent, LevelDef, SceneState } from './types'; + +/** Stato di scena al passo 0: derivato dal livello, prima di ogni evento. */ +export function initScene(level: LevelDef): SceneState { + const enemies: SceneState['enemies'] = {}; + for (const e of level.enemies ?? []) { + enemies[e.id] = { kind: e.kind, x: e.x, y: e.y, hp: e.hp, alive: true }; + } + const resources: SceneState['resources'] = {}; + for (const r of level.resources ?? []) { + resources[r.id] = { kind: r.kind, x: r.x, y: r.y, collected: false }; + } + return { + hero: { + x: level.hero.x, + y: level.hero.y, + facing: level.hero.facing, + hp: level.hero.hp, + }, + enemies, + resources, + inventory: {}, + won: false, + dead: false, + stepLimit: false, + fx: null, + }; +} + +/** Applica un evento a uno stato, restituendo un nuovo stato (immutabile). */ +export function applyGameEvent(prev: SceneState, ev: GameEvent): SceneState { + // Clona in superficie: `fx` riparte sempre da null (transiente). + const s: SceneState = { + ...prev, + hero: { ...prev.hero }, + enemies: { ...prev.enemies }, + resources: { ...prev.resources }, + inventory: { ...prev.inventory }, + fx: null, + }; + + switch (ev.t) { + case 'move': + s.hero.x = ev.x; + s.hero.y = ev.y; + s.hero.facing = ev.f; + break; + case 'turn': + s.hero.facing = ev.f; + break; + case 'bump': + s.fx = { kind: 'bump', at: { x: ev.x, y: ev.y } }; + break; + case 'attack': + s.fx = { kind: 'attack', at: { x: ev.x, y: ev.y } }; + break; + case 'enemy_hit': { + const e = s.enemies[ev.id]; + if (e) s.enemies[ev.id] = { ...e, hp: ev.hp }; + break; + } + case 'defeat': { + const e = s.enemies[ev.id]; + if (e) { + s.enemies[ev.id] = { ...e, hp: 0, alive: false }; + s.fx = { kind: 'defeat', at: { x: e.x, y: e.y } }; + } + break; + } + case 'enemy_move': { + const e = s.enemies[ev.id]; + if (e) s.enemies[ev.id] = { ...e, x: ev.x, y: ev.y }; + break; + } + case 'hero_hit': + s.hero.hp = ev.hp; + s.fx = { kind: 'hero_hit', at: { x: s.hero.x, y: s.hero.y } }; + break; + case 'collect': { + const r = s.resources[ev.id]; + if (r) s.resources[ev.id] = { ...r, collected: true }; + s.inventory[ev.kind] = (s.inventory[ev.kind] ?? 0) + 1; + s.fx = { kind: 'collect', at: { x: s.hero.x, y: s.hero.y } }; + break; + } + case 'win': + s.won = true; + break; + case 'death': + s.dead = true; + break; + case 'step_limit': + s.stepLimit = true; + break; + default: { + // Exhaustive check: se GameEvent cresce, il compilatore segnala qui. + const _exhaustive: never = ev; + return _exhaustive; + } + } + + return s; +} diff --git a/src/components/PyQuest/characters.tsx b/src/components/PyQuest/characters.tsx new file mode 100644 index 0000000..caf742b --- /dev/null +++ b/src/components/PyQuest/characters.tsx @@ -0,0 +1,183 @@ +/** + * Registry data-driven dei personaggi PyQuest (spec D13). + * + * Ogni personaggio ha uno sprite SVG inline (colori via CSS var → dark mode + * gratis, definite in GameScene.module.css) e i flavor text di esito nel suo + * registro. Lo sprite è disegnato in vista dall'alto rivolto a nord: il + * renderer lo orienta nelle 4 direzioni con una rotate. + * + * V1 implementa Byte completo; `monty` è la predisposizione dati per il volume + * futuro (sprite placeholder, la struttura è quella definitiva). + */ + +import type { ReactElement } from 'react'; +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { + faGhost, + faSkull, + faDragon, + faSpider, + faBug, + faGem, + faKey, + faCoins, + faStar, + faHeart, + faFlagCheckered, +} from '@fortawesome/free-solid-svg-icons'; + +export interface CharacterDef { + /** Nome proprio, usato nei pannelli di esito. */ + name: string; + /** Sprite SVG inline, vista dall'alto, rivolto a nord (il renderer ruota). */ + sprite: ReactElement; + /** Flavor text di esito, nel registro del personaggio (sarcasmo ≈3/5). */ + flavor: { + win: string; + death: string; + stepLimit: (maxSteps: number) => string; + /** Bump ripetuto: il codice è terminato ma l'eroe ha sbattuto più volte. */ + bump: string; + }; +} + +/* Byte, il droide esploratore: corpo circolare con cingoli laterali e un + occhio-LED al «muso» che rende leggibile il facing anche ruotato. Sprite + provvisorio «da validare» (l'asset definitivo può sostituirlo qui). */ +const byteSprite = ( + +); + +/* Monty, il pitone col cappello (volume futuro): placeholder minimale, la + entry esiste solo perché la struttura dati deve esserci già in v1. */ +const montySprite = ( + +); + +export const CHARACTERS: Record = { + byte: { + name: 'Byte', + sprite: byteSprite, + flavor: { + win: 'Obiettivo raggiunto. I miei circuiti esultano — in binario, ovviamente.', + death: + 'Integrità dello scafo: 0%. Nel mio report scriverò «atterraggio molto ravvicinato su un nemico». Riavviami e riproviamo.', + stepLimit: (maxSteps) => + `${maxSteps + 1} mosse e nessuna uscita: o il labirinto è infinito, o quel \`while\` non si ferma mai. Scommetto sul \`while\`.`, + bump: 'Hai sbattuto contro un muro. Di nuovo. Aggiorno il mio report assicurativo.', + }, + }, + monty: { + name: 'Monty', + sprite: montySprite, + flavor: { + win: 'Missione compiuta. Niente applausi, grazie: mi cadrebbe il cappello.', + death: 'Sono svenuto nel modo più dignitoso possibile. Riproviamo.', + stepLimit: (maxSteps) => + `${maxSteps} mosse senza arrivare da nessuna parte. Persino io mi sono annoiato, e sono un rettile.`, + bump: 'Quel muro era lì anche il turno scorso. E anche quello prima.', + }, + }, +}; + +const CHARACTER_FALLBACK = CHARACTERS.byte; + +export function characterDef(id: string): CharacterDef { + return CHARACTERS[id] ?? CHARACTER_FALLBACK; +} + +// --- Nemici e risorse (icone Font Awesome, colori via CSS var) ----------------- + +export interface Visual { + icon: IconDefinition; + /** Colore CSS (token del tema di scena). */ + color: string; + /** Etichetta accessibile. */ + label: string; +} + +const ENEMIES: Record = { + bug: { icon: faBug, color: 'var(--pq-enemy)', label: 'bug' }, + ghost: { icon: faGhost, color: 'var(--pq-enemy)', label: 'fantasma' }, + dragon: { icon: faDragon, color: 'var(--pq-enemy)', label: 'drago' }, + spider: { icon: faSpider, color: 'var(--pq-enemy)', label: 'ragno' }, +}; + +/** Kind con uno sprite dedicato (l'editor livelli li offre nei menu). */ +export const ENEMY_KINDS = Object.keys(ENEMIES); + +const ENEMY_FALLBACK: Visual = { + icon: faSkull, + color: 'var(--pq-enemy)', + label: 'nemico', +}; + +export function enemyVisual(kind: string): Visual { + return ENEMIES[kind] ?? ENEMY_FALLBACK; +} + +const RESOURCES: Record = { + gem: { icon: faGem, color: 'var(--pq-resource)', label: 'gemma' }, + key: { icon: faKey, color: 'var(--pq-resource)', label: 'chiave' }, + coin: { icon: faCoins, color: 'var(--pq-resource)', label: 'moneta' }, + heart: { icon: faHeart, color: 'var(--pq-resource)', label: 'cuore' }, +}; + +/** Kind con uno sprite dedicato (l'editor livelli li offre nei menu). */ +export const RESOURCE_KINDS = Object.keys(RESOURCES); + +const RESOURCE_FALLBACK: Visual = { + icon: faStar, + color: 'var(--pq-resource)', + label: 'risorsa', +}; + +export function resourceVisual(kind: string): Visual { + return RESOURCES[kind] ?? RESOURCE_FALLBACK; +} + +// --- Icone di scena non-personaggio ------------------------------------------- + +export const GOAL_ICON = faFlagCheckered; +export const HP_ICON = faHeart; diff --git a/src/components/PyQuest/index.tsx b/src/components/PyQuest/index.tsx new file mode 100644 index 0000000..9b8d395 --- /dev/null +++ b/src/components/PyQuest/index.tsx @@ -0,0 +1,497 @@ +/** + * PyQuest — mini-giochi a griglia con Python (spec step 12). + * + * Orchestrazione: editor + Toolbar (riusati da PyRunner) → `runLevel` esegue il + * codice studente contro il motore trace-based (`static/bry-libs/pyquest.py`), + * gli eventi si accumulano in un ref, e a fine esecuzione `GamePlayer` anima la + * trace su `GameScene`. + * + * Macchina a stati: `idle → executing (invisibile, ms) → animating → finished`. + * L'esito è calcolato dalla trace con precedenza **won > failed > error** + * (emendamento D7): dopo la vittoria la trace può contenere anche `step_limit` + * e uno stderr — lo studente ha comunque vinto, il traceback resta in console. + * + * Risoluzione del livello: `levelData` inline (editor/test) ha la precedenza; + * altrimenti `world`+`level` fanno lookup nei global data del plugin `pyquest`. + * Id ignoti = pannello d'errore, mai un livello di ripiego silenzioso. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import { usePluginData } from '@docusaurus/useGlobalData'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faStar } from '@fortawesome/free-solid-svg-icons'; +import { faStar as faStarOutline } from '@fortawesome/free-regular-svg-icons'; +import clsx from 'clsx'; +import { Editor, type EditorHandle } from '@site/src/theme/PyRunner/Editor'; +import { Toolbar } from '@site/src/theme/PyRunner/Toolbar'; +import type { RunStatus } from '@site/src/theme/PyRunner/types'; +import { copyToClipboard } from '@site/src/theme/PyRunner/clipboard'; +import { + buildExplainText, + DEFAULT_EXPLAIN_PROMPT, +} from '@site/src/theme/PyRunner/share'; +import { ensureBrython, type BrythonConfig } from '@site/src/pyBoot'; +import pyStyles from '@site/src/theme/PyRunner/styles.module.css'; +import { runLevel, MAX_STEPS_DEFAULT } from './runLevel'; +import { useWorldData } from './useWorldData'; +import { useProgress, countActions, starsFor } from './useProgress'; +import { characterDef } from './characters'; +import GamePlayer from './GamePlayer'; +import HintPanel from './HintPanel'; +import type { GameEvent, LevelDef, LogLine } from './types'; +import styles from './PyQuest.module.css'; + +export interface PyQuestProps { + /** Id del mondo nei global data del plugin `pyquest`. */ + world?: string; + /** Id del livello dentro il mondo. */ + level?: string; + /** Livello passato inline (editor/test): bypassa la lookup su world/level. */ + levelData?: LevelDef; + /** Personaggio (override): normalmente ereditato dal mondo. */ + character?: string; + /** Titolo mostrato nella toolbar (override del titolo del livello). */ + title?: string; + /** + * Notifica una vittoria a fine animazione (la galleria vi aggancia la + * navigazione al livello successivo). Il progresso è già stato salvato quando + * scatta. Passa una callback stabile (useCallback) per evitare rifiri. + */ + onWin?: (result: { steps: number; stars: number; isRecord: boolean }) => void; +} + +interface PyRunnerGlobalData { + libUrl: string; + brython?: BrythonConfig; +} + +type Phase = 'idle' | 'executing' | 'animating' | 'finished'; +/** `null` = il codice è terminato senza vittoria, sconfitta né errore. */ +type Outcome = 'won' | 'failed' | 'error' | null; + +// Contatore di modulo per il suffisso del codeId (D12): due istanze dello stesso +// livello sulla stessa pagina devono restare indipendenti. +let idCounter = 0; + +function makeCodeId(seed: string, n: number): string { + let hash = 5381; + for (let i = 0; i < seed.length; i++) { + hash = (hash * 33) ^ seed.charCodeAt(i); + } + // Deve soddisfare la guardia /^pyr_[a-z0-9]+$/ di bryBridge (niente separatori). + // Hash a larghezza fissa (7 = max cifre base36 di un uint32): senza padding la + // concatenazione hash+contatore potrebbe collidere tra istanze diverse. + const h = (hash >>> 0).toString(36).padStart(7, '0'); + return `pyr_${h}${n.toString(36)}`; +} + +function ErrorBox({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +/** «1 azione» / «N azioni». */ +function azioni(n: number): string { + return n === 1 ? 'azione' : 'azioni'; +} + +/** Riga di 3 stelle, piene fino a `n` (spec D11). */ +function Stars({ n }: { n: number }) { + return ( + + {[1, 2, 3].map((i) => ( + + ))} + + ); +} + +function PyQuestInner(props: PyQuestProps) { + const pyrunner = usePluginData('pyrunner') as PyRunnerGlobalData | undefined; + const worlds = useWorldData(); + const { getLevel, recordWin } = useProgress(); + const libUrl = pyrunner?.libUrl ?? ''; + const brython = pyrunner?.brython; + + // Risoluzione livello + personaggio (prima degli hook condizionali: i render + // d'errore stanno in fondo, dopo tutti gli hook). + const world = props.world ? worlds[props.world] : undefined; + const level: LevelDef | undefined = + props.levelData ?? + (world && props.level + ? world.levels.find((l) => l.id === props.level) + : undefined); + const character = props.character ?? world?.character ?? 'byte'; + const char = characterDef(character); + const starterCode = level?.starterCode ?? ''; + + // Progresso salvato: solo per livelli identificati da world+level (non per i + // livelli inline dell'editor, che non hanno un id persistibile). + const savedProgress = + props.world && props.level ? getLevel(props.world, props.level) : undefined; + + const seed = + props.world && props.level + ? `${props.world}/${props.level}` + : JSON.stringify(level ?? props); + const instanceN = useRef(-1); + if (instanceN.current < 0) instanceN.current = idCounter++; + const codeId = makeCodeId(seed, instanceN.current); + + const [phase, setPhase] = useState('idle'); + const [outcome, setOutcome] = useState(null); + const [events, setEvents] = useState([]); + const [logs, setLogs] = useState([]); + const [playKey, setPlayKey] = useState(0); + const [hasEdits, setHasEdits] = useState(false); + const [currentCode, setCurrentCode] = useState(starterCode); + // Nuovo record di QUESTO run: deciso da recordWin PRIMA di scrivere il best + // (dopo la scrittura un pareggio sarebbe indistinguibile da un record). + const [isRecord, setIsRecord] = useState(false); + + const editorRef = useRef(null); + const rootRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + const eventsRef = useRef([]); + const logsRef = useRef([]); + // Il livello era già completato all'avvio del run? Serve a non far comparire + // la striscia «Completato» durante l'animazione della prima vittoria (recordWin + // scatta a inizio animazione: la striscia rivelerebbe l'esito in anticipo). + const wasDoneBeforeRunRef = useRef(false); + + // Precarica Brython quando il componente entra nel viewport (pattern PyRunner). + useEffect(() => { + const el = rootRef.current; + const preload = () => { + ensureBrython(libUrl, brython).catch(() => { + /* l'errore riemerge al primo run via onError */ + }); + }; + if (!el || typeof IntersectionObserver === 'undefined') { + preload(); + return; + } + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + io.disconnect(); + preload(); + } + }, + { rootMargin: '200px' }, + ); + io.observe(el); + return () => io.disconnect(); + }, [libUrl, brython]); + + useEffect(() => () => cleanupRef.current?.(), []); + + // Cambio di livello a runtime (anteprima live dell'editor livelli): la trace + // del run precedente non descrive più il livello — annulla l'eventuale run e + // torna a idle. Per i livelli da world data l'identità è stabile: non scatta. + const levelRef = useRef(level); + useEffect(() => { + if (levelRef.current === level) return; + levelRef.current = level; + cleanupRef.current?.(); + cleanupRef.current = null; + eventsRef.current = []; + logsRef.current = []; + setPhase('idle'); + setOutcome(null); + setIsRecord(false); + setEvents([]); + setLogs([]); + }, [level]); + + // Anche starterCode può cambiare a runtime (form dell'editor livelli): + // l'Editor monta initialCode una sola volta, quindi va allineato a mano — + // ma solo finché non ci sono modifiche manuali da preservare. + useEffect(() => { + if (!hasEdits) { + editorRef.current?.setCode(starterCode); + setCurrentCode(starterCode); + } + }, [starterCode, hasEdits]); + + const finishRun = useCallback(() => { + const trace = eventsRef.current; + const hasWin = trace.some((e) => e.t === 'win'); + const hasDeath = trace.some((e) => e.t === 'death'); + const hasLimit = trace.some((e) => e.t === 'step_limit'); + const hasStderr = logsRef.current.some((l) => l.kind === 'stderr'); + // Precedenza esplicita (emendamento D7): won > failed > error. + setOutcome( + hasWin + ? 'won' + : hasDeath || hasLimit + ? 'failed' + : hasStderr + ? 'error' + : null, + ); + // Vittoria: salva completamento e record (solo per livelli identificabili). + if (hasWin && props.world && props.level) { + setIsRecord(recordWin(props.world, props.level, countActions(trace))); + } + setEvents([...trace]); + setLogs([...logsRef.current]); + setPhase('animating'); + setPlayKey((k) => k + 1); + }, [recordWin, props.world, props.level]); + + const handleExplain = useCallback(() => { + if (!level) return; + const code = editorRef.current?.getCode() ?? starterCode; + const contextTitle = `Livello PyQuest «${level.title}»`; + const text = buildExplainText(DEFAULT_EXPLAIN_PROMPT, code, contextTitle); + copyToClipboard(text).catch(() => { + /* clipboard non disponibile: nessun feedback, il codice resta nell'editor */ + }); + }, [level, starterCode]); + + const handleRun = useCallback(() => { + if (!level) return; + const code = editorRef.current?.getCode() ?? starterCode; + wasDoneBeforeRunRef.current = savedProgress?.done === true; + setPhase('executing'); + setOutcome(null); + setIsRecord(false); + setEvents([]); + setLogs([]); + eventsRef.current = []; + logsRef.current = []; + cleanupRef.current?.(); + cleanupRef.current = runLevel({ + code, + level, + codeId, + libUrl, + brython, + onStart: () => { + eventsRef.current = []; + logsRef.current = []; + }, + onEvent: (ev) => { + eventsRef.current.push(ev); + }, + onLog: (kind, text) => { + logsRef.current.push({ kind, text, atStep: eventsRef.current.length }); + }, + onDone: finishRun, + onError: (err) => { + logsRef.current.push({ + kind: 'stderr', + text: `[PyQuest] ${err.message}\n`, + atStep: eventsRef.current.length, + }); + finishRun(); + }, + }); + }, [level, starterCode, codeId, libUrl, brython, finishRun, savedProgress]); + + const handleReset = useCallback(() => { + editorRef.current?.setCode(starterCode); + setCurrentCode(starterCode); + setHasEdits(false); + }, [starterCode]); + + const handleChange = useCallback( + (next: string) => { + setCurrentCode(next); + setHasEdits(next !== starterCode); + }, + [starterCode], + ); + + // Fine animazione: il player è arrivato in fondo alla trace. + const handleStepChange = useCallback((step: number, total: number) => { + if (step >= total) { + setPhase((p) => (p === 'animating' ? 'finished' : p)); + } + }, []); + + // Vittoria conclusa: segnala al genitore (la galleria sblocca il «prossimo»). + const { onWin } = props; + useEffect(() => { + if (phase !== 'finished' || outcome !== 'won' || !level || !onWin) return; + const steps = countActions(events); + onWin({ steps, stars: starsFor(steps, level.par), isRecord }); + }, [phase, outcome, level, events, isRecord, onWin]); + + if (!libUrl) { + return ( + + PyQuest: plugin pyrunner non registrato — libUrl mancante. + + ); + } + if (!level) { + if (props.world && !world) { + return ( + + PyQuest: mondo {props.world} non trovato nei dati del + plugin. + + ); + } + if (world && props.level) { + return ( + + PyQuest: livello {props.level} non trovato nel mondo{' '} + {props.world}. + + ); + } + return ( + + PyQuest: specifica world + level oppure{' '} + levelData. + + ); + } + + const toolbarStatus: RunStatus = + phase === 'executing' + ? 'running' + : phase === 'idle' + ? 'idle' + : outcome === 'error' + ? 'error' + : 'done'; + + const maxSteps = level.maxSteps ?? MAX_STEPS_DEFAULT; + const hasDeath = events.some((e) => e.t === 'death'); + const bumpCount = events.filter((e) => e.t === 'bump').length; + const stderrText = logs + .filter((l) => l.kind === 'stderr') + .map((l) => l.text) + .join(''); + // Il traceback completo è già nella console del player: il pannello ne mostra + // solo l'ultima riga — tipo di errore e messaggio, cioè la parte che dice allo + // studente cosa correggere. + const errorSummary = + stderrText + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .pop() ?? ''; + + // Azioni e stelle di QUESTA esecuzione (il pannello vittoria le mostra); + // `savedProgress` porta invece il record persistito, visibile anche a freddo. + const runSteps = countActions(events); + const runStars = starsFor(runSteps, level.par); + // La striscia «Completato» non deve anticipare l'esito durante l'animazione + // della vittoria che l'ha appena sbloccata (recordWin scrive a inizio replay). + const showCompleted = + savedProgress?.done === true && + (phase === 'idle' || phase === 'finished' || wasDoneBeforeRunRef.current); + + return ( +
+
+
+ {/* Record persistito: visibile anche a freddo (verifica reload). */} + {showCompleted && savedProgress && ( +
+ + + Completato · record {savedProgress.bestSteps}{' '} + {azioni(savedProgress.bestSteps)} + +
+ )} + + + + {/* Pannelli di esito con il flavor text del personaggio (D13). */} + {phase === 'finished' && outcome === 'won' && ( +
+
+ + + {runSteps} {azioni(runSteps)} · par {level.par} + {isRecord && ( + · nuovo record! + )} + +
+

+ {char.name}: {char.flavor.win} +

+
+ )} + {phase === 'finished' && outcome === 'failed' && ( +
+ {char.name}:{' '} + {hasDeath ? char.flavor.death : char.flavor.stepLimit(maxSteps)} +
+ )} + {phase === 'finished' && outcome === 'error' && ( +
+

C’è un errore nel codice

+
{errorSummary}
+

+ Il traceback completo, con la riga esatta, è qui sopra nella + console. +

+
+ )} + {phase === 'finished' && outcome === null && bumpCount >= 2 && ( +
+ {char.name}: {char.flavor.bump} +
+ )} +
+ +
+ +
+ +
+ {/* key: al cambio livello (anteprima live dell'editor) i suggerimenti + rivelati ripartono da zero. */} + +
+
+
+ ); +} + +export default function PyQuest(props: PyQuestProps) { + return ( + PyQuest…
}> + {() => } + + ); +} diff --git a/src/components/PyQuest/runLevel.ts b/src/components/PyQuest/runLevel.ts new file mode 100644 index 0000000..f0776d3 --- /dev/null +++ b/src/components/PyQuest/runLevel.ts @@ -0,0 +1,91 @@ +/** + * Esegue il codice studente contro un livello PyQuest. + * + * Compone il preCode (D2/D7): `from pyquest import *` + `_load_level()` + + * shadowing di `input`, poi delega a `runPython` (bryBridge). Gli eventi di + * gioco arrivano via `onCustom` come `{type:'game_event', payload:''}` + * (D4): qui il payload viene riparsato in un `GameEvent` tipato. + * + * Il JSON del livello iniettato è **spogliato dei campi testuali** + * (title/hints/starterCode/solution): al motore servono solo grid/hero/goal/ + * enemies/resources/win/maxSteps. Meno quoting, payload più leggero. + */ + +import type { BrythonConfig } from '@site/src/pyBoot'; +import { runPython, type LogKind } from '@site/src/theme/PyRunner/bryBridge'; +import type { GameEvent, LevelDef } from './types'; + +export const MAX_STEPS_DEFAULT = 500; + +/** Sottoinsieme del livello che il motore Python consuma davvero. */ +function runtimeLevel(level: LevelDef): Record { + const rt: Record = { + grid: level.grid, + hero: level.hero, + enemies: level.enemies ?? [], + resources: level.resources ?? [], + win: level.win, + maxSteps: level.maxSteps ?? MAX_STEPS_DEFAULT, + }; + if (level.goal) rt.goal = level.goal; + return rt; +} + +function buildPreCode(level: LevelDef): string { + const json = JSON.stringify(runtimeLevel(level)); + return [ + 'from pyquest import *', + `_load_level('''${json}''')`, + "def input(*a): raise RuntimeError('input() non è disponibile nei livelli: usa i sensori!')", + ].join('\n'); +} + +export interface RunLevelOptions { + code: string; + level: LevelDef; + codeId: string; + libUrl: string; + brython?: BrythonConfig; + onStart: () => void; + onEvent: (ev: GameEvent) => void; + onLog: (kind: LogKind, text: string) => void; + onDone: (durationMs: number) => void; + onError?: (err: Error) => void; +} + +/** Avvia un run. Ritorna una funzione di cleanup (rimuove il listener). */ +export function runLevel(opts: RunLevelOptions): () => void { + const { + code, + level, + codeId, + libUrl, + brython, + onStart, + onEvent, + onLog, + onDone, + onError, + } = opts; + + return runPython(code, { + codeId, + preCode: buildPreCode(level), + libUrl, + brython, + onStart, + onLog, + onDone, + onError, + onCustom: (detail) => { + if (detail.type !== 'game_event') return; + const payload = detail.payload; + if (typeof payload !== 'string') return; + try { + onEvent(JSON.parse(payload) as GameEvent); + } catch { + // payload malformato: ignora (non deve mai capitare — lo emettiamo noi) + } + }, + }); +} diff --git a/src/components/PyQuest/types.ts b/src/components/PyQuest/types.ts new file mode 100644 index 0000000..6173cc7 --- /dev/null +++ b/src/components/PyQuest/types.ts @@ -0,0 +1,120 @@ +/** + * Tipi di PyQuest: la trace di eventi emessi dal motore Python + * (`static/bry-libs/pyquest.py`), lo stato di scena derivato consumato dal + * renderer, e i tipi dei dati livello/mondo speculari allo schema world.json. + */ + +export type Facing = 'north' | 'east' | 'south' | 'west'; + +/** Un evento della trace. Il campo discriminante è `t`. */ +export type GameEvent = + | { t: 'move'; x: number; y: number; f: Facing } + | { t: 'turn'; f: Facing } + | { t: 'bump'; x: number; y: number } + | { t: 'attack'; x: number; y: number; hit: boolean } + | { t: 'enemy_hit'; id: string; hp: number } + | { t: 'defeat'; id: string } + | { t: 'enemy_move'; id: string; x: number; y: number } + | { t: 'hero_hit'; hp: number; by: string } + | { t: 'collect'; id: string; kind: string } + | { t: 'win' } + | { t: 'death' } + | { t: 'step_limit' }; + +/** Riga di console associata al passo della trace in cui è stata emessa. */ +export interface LogLine { + kind: 'stdout' | 'stderr'; + text: string; + atStep: number; +} + +/** Stato di scena a un dato passo: derivato riducendo la trace. */ +export interface SceneState { + hero: { x: number; y: number; facing: Facing; hp: number }; + enemies: Record< + string, + { kind: string; x: number; y: number; hp: number; alive: boolean } + >; + resources: Record< + string, + { kind: string; x: number; y: number; collected: boolean } + >; + inventory: Record; + won: boolean; + dead: boolean; + stepLimit: boolean; + /** Transiente: valido solo per lo step corrente (animazioni one-shot). */ + fx: { + kind: 'bump' | 'attack' | 'hero_hit' | 'collect' | 'defeat'; + at?: { x: number; y: number }; + } | null; +} + +// --- Schema dati (speculare a static/pyquest//world.json) ------------- + +export interface GridDef { + legend: Record; + rows: string[]; +} + +export interface HeroDef { + x: number; + y: number; + facing: Facing; + hp: number; +} + +export interface PointDef { + x: number; + y: number; +} + +export type EnemyBehavior = 'static' | 'melee'; + +export interface EnemyDef { + id: string; + kind: string; + x: number; + y: number; + hp: number; + behavior: EnemyBehavior; +} + +export interface ResourceDef { + id: string; + kind: string; + x: number; + y: number; +} + +export type WinCond = + | { type: 'reach' } + | { type: 'collect'; kind: string; qty: number } + | { type: 'defeat'; kind?: string; count?: number }; + +export interface LevelDef { + id: string; + title: string; + grid: GridDef; + hero: HeroDef; + goal?: PointDef; + enemies?: EnemyDef[]; + resources?: ResourceDef[]; + win: WinCond[]; + starterCode: string; + /** Solo per l'autore (calibrazione del `par`); non usato a runtime. */ + solution?: string; + hints: string[]; + par: number; + maxSteps?: number; +} + +export interface WorldDef { + schemaVersion: number; + id: string; + title: string; + description: string; + character: string; + volume: string; + levels: LevelDef[]; +} diff --git a/src/components/PyQuest/useProgress.ts b/src/components/PyQuest/useProgress.ts new file mode 100644 index 0000000..d1afcc3 --- /dev/null +++ b/src/components/PyQuest/useProgress.ts @@ -0,0 +1,176 @@ +/** + * Progressione di PyQuest (spec D10): completamenti e record per livello, + * persistiti in localStorage sotto un'unica chiave versionata. + * + * - Chiave `pdb:pyquest:progress` (convenzione `pdb:` = stato di dominio). + * - Lo **sblocco non è salvato**: un livello è giocabile se è il primo o se il + * precedente nell'array del mondo è `done` (`isUnlocked`). Inserire un livello + * in mezzo ri-blocca solo ciò che segue; rimuoverne uno lascia chiavi orfane + * innocue. + * - Parse difensivo: JSON corrotto o `version` ignota → reset silenzioso. + * - Sincronizzazione same-tab via `CustomEvent` (l'evento `storage` non scatta + * nella tab che scrive) + `storage` cross-tab (pattern `Algorithm/usePref`). + */ + +import { useCallback, useEffect, useState } from 'react'; +import type { GameEvent, WorldDef } from './types'; + +const KEY = 'pdb:pyquest:progress'; +const EVENT = 'pdb-pyquest-progress'; +const VERSION = 1; + +export interface LevelProgress { + done: boolean; + bestSteps: number; +} + +interface ProgressShape { + version: number; + worlds: Record }>; +} + +const EMPTY: ProgressShape = { version: VERSION, worlds: {} }; + +function readProgress(): ProgressShape { + if (typeof window === 'undefined') return EMPTY; + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return EMPTY; + const parsed = JSON.parse(raw); + if ( + !parsed || + parsed.version !== VERSION || + typeof parsed.worlds !== 'object' + ) { + return EMPTY; // corrotto o versione ignota → reset + } + return parsed as ProgressShape; + } catch { + return EMPTY; + } +} + +function writeProgress(next: ProgressShape) { + try { + window.localStorage.setItem(KEY, JSON.stringify(next)); + } catch { + /* storage non disponibile: la sessione resta valida in memoria */ + } + window.dispatchEvent(new CustomEvent(EVENT)); +} + +export interface UseProgress { + /** Progresso salvato di un livello (undefined se mai completato). */ + getLevel(worldId: string, levelId: string): LevelProgress | undefined; + /** + * Registra una vittoria: marca `done` e minimizza `bestSteps`. Ritorna true + * se è un nuovo record (prima vittoria o miglioramento **stretto** del best): + * va deciso qui, prima della scrittura — dopo, il best salvato coincide col + * run e un pareggio sarebbe indistinguibile da un record. + */ + recordWin(worldId: string, levelId: string, steps: number): boolean; + /** Sblocco calcolato: primo livello o precedente `done` (spec D10). */ + isUnlocked(world: WorldDef, levelId: string): boolean; +} + +export function useProgress(): UseProgress { + const [progress, setProgress] = useState(EMPTY); + + // Dopo l'hydration leggo il valore reale (sul server era EMPTY). È una lettura + // di sincronizzazione con un sistema esterno (localStorage), non uno stato + // derivato: il set al mount è voluto e SSR-safe (parte da EMPTY, poi allinea). + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setProgress(readProgress()); + }, []); + + // Allineamento con gli altri blocchi (same-tab) e con le altre tab (storage). + useEffect(() => { + const onLocal = () => setProgress(readProgress()); + const onStorage = (e: StorageEvent) => { + if (e.key === KEY) setProgress(readProgress()); + }; + window.addEventListener(EVENT, onLocal); + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener(EVENT, onLocal); + window.removeEventListener('storage', onStorage); + }; + }, []); + + const getLevel = useCallback( + (worldId: string, levelId: string): LevelProgress | undefined => + progress.worlds[worldId]?.levels[levelId], + [progress], + ); + + const recordWin = useCallback( + (worldId: string, levelId: string, steps: number): boolean => { + // Rileggo dallo storage prima di scrivere: evita di sovrascrivere + // aggiornamenti fatti da un altro blocco tra un render e l'altro. + const current = readProgress(); + const world = current.worlds[worldId] ?? { levels: {} }; + const prev = world.levels[levelId]; + const isRecord = prev === undefined || steps < prev.bestSteps; + const bestSteps = prev ? Math.min(prev.bestSteps, steps) : steps; + const next: ProgressShape = { + ...current, + worlds: { + ...current.worlds, + [worldId]: { + ...world, + levels: { + ...world.levels, + [levelId]: { done: true, bestSteps }, + }, + }, + }, + }; + writeProgress(next); + setProgress(next); // aggiorna subito questo blocco; l'evento fa gli altri + return isRecord; + }, + [], + ); + + const isUnlocked = useCallback( + (world: WorldDef, levelId: string): boolean => { + const idx = world.levels.findIndex((l) => l.id === levelId); + if (idx <= 0) return true; // primo livello (o id ignoto): sempre aperto + const prevId = world.levels[idx - 1].id; + return Boolean(progress.worlds[world.id]?.levels[prevId]?.done); + }, + [progress], + ); + + return { getLevel, recordWin, isUnlocked }; +} + +// --- Calcolo azioni e stelle -------------------------------------------------- + +/** + * Eventi che contano come «azione» dello studente (spec step 16): gli eventi + * derivati dal mondo (`enemy_*`, `hero_hit`, `defeat`, `win`, `death`, + * `step_limit`) non contano. + */ +const ACTION_TYPES: ReadonlyArray = [ + 'move', + 'turn', + 'bump', + 'attack', + 'collect', +]; + +/** Numero di azioni fino al `win` incluso (o su tutta la trace se non c'è). */ +export function countActions(events: GameEvent[]): number { + const winIdx = events.findIndex((e) => e.t === 'win'); + const upto = winIdx >= 0 ? events.slice(0, winIdx) : events; + return upto.reduce((n, e) => n + (ACTION_TYPES.includes(e.t) ? 1 : 0), 0); +} + +/** Stelle da `par` (spec D11): ≤ par → 3; ≤ ⌈par·1,5⌉ → 2; completato → 1. */ +export function starsFor(steps: number, par: number): 1 | 2 | 3 { + if (steps <= par) return 3; + if (steps <= Math.ceil(par * 1.5)) return 2; + return 1; +} diff --git a/src/components/PyQuest/useWorldData.ts b/src/components/PyQuest/useWorldData.ts new file mode 100644 index 0000000..7ae4399 --- /dev/null +++ b/src/components/PyQuest/useWorldData.ts @@ -0,0 +1,16 @@ +/** + * Hook sui global data del plugin `pyquest` (spec step 12): i mondi validati a + * build time da plugins/pyquest/index.js. + */ + +import { usePluginData } from '@docusaurus/useGlobalData'; +import type { WorldDef } from './types'; + +interface PyQuestGlobalData { + worlds: Record; +} + +export function useWorldData(): Record { + const data = usePluginData('pyquest') as PyQuestGlobalData | undefined; + return data?.worlds ?? {}; +} diff --git a/src/pages/legale/privacy-policy.mdx b/src/pages/legale/privacy-policy.mdx index 5c4496c..50d5f77 100644 --- a/src/pages/legale/privacy-policy.mdx +++ b/src/pages/legale/privacy-policy.mdx @@ -112,9 +112,7 @@ Le richieste possono essere inviate all'indirizzo [marco@rainbowbits.cloud](mail La presente informativa potrà essere aggiornata; le eventuali modifiche saranno pubblicate su questa pagina con indicazione della data di ultimo aggiornamento. - - Tienilo segreto. Tienilo al sicuro. - +Tienilo segreto. Tienilo al sicuro. --- diff --git a/src/pages/level-editor.module.css b/src/pages/level-editor.module.css new file mode 100644 index 0000000..690d1ed --- /dev/null +++ b/src/pages/level-editor.module.css @@ -0,0 +1,364 @@ +/* Editor livelli PyQuest (step 17). Strumento d'autore desktop-first. */ + +.page { + max-width: 1400px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +.header { + margin-bottom: 1.5rem; +} +.header h1 { + margin-bottom: 0.35rem; +} +.header p { + color: var(--ifm-color-emphasis-700); + max-width: 60ch; + margin: 0; +} +.headerIcon { + color: var(--ifm-color-primary); +} + +.loading { + padding: 3rem; + text-align: center; + color: var(--ifm-color-emphasis-600); +} + +/* Layout a due colonne (canvas | form). */ +.editor { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1.25rem; + align-items: start; +} +@media (max-width: 996px) { + .editor { + grid-template-columns: minmax(0, 1fr); + } +} + +.canvasCol, +.formCol { + display: flex; + flex-direction: column; + gap: 1.25rem; + min-width: 0; +} + +.panel { + background: var( + --ifm-card-background-color, + var(--ifm-background-surface-color) + ); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + padding: 1rem 1.1rem; +} + +.panelTitle { + font-size: 1rem; + margin: 0 0 0.6rem; +} +.panelHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.6rem; +} +.panelHead .panelTitle { + margin: 0; +} + +.hint { + font-size: 0.82rem; + color: var(--ifm-color-emphasis-600); + margin: 0 0 0.75rem; + line-height: 1.4; +} +.hint code { + font-size: 0.78rem; +} + +/* Palette + resize. */ +.paletteRow { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; +} +.palette { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.paletteBtn { + border: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + border-radius: var(--radius-sm); + padding: 0.35rem 0.7rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: + background var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out); +} +.paletteBtn:hover { + border-color: var(--ifm-color-primary); +} +.paletteBtn:active { + transform: scale(0.98); +} +.paletteActive { + background: var(--ifm-color-primary); + border-color: var(--ifm-color-primary); + color: #fff; +} + +.sizeCtrl { + display: flex; + gap: 0.75rem; +} +.sizeCtrl label { + display: flex; + flex-direction: column; + font-size: 0.72rem; + font-weight: 600; + color: var(--ifm-color-emphasis-600); + gap: 0.2rem; +} +.sizeCtrl input { + width: 4rem; +} + +/* Griglia editabile. Cella a lato fisso (strumento desktop-first): niente + percentuali circolari con `width: fit-content`. */ +.board { + --ed-cell: 40px; + display: grid; + grid-template-columns: repeat(var(--ed-cols), var(--ed-cell)); + grid-template-rows: repeat(var(--ed-rows), var(--ed-cell)); + gap: 2px; + padding: 6px; + background: var(--ed-board-bg); + border-radius: var(--radius-sm); + width: fit-content; + max-width: 100%; + overflow: auto; + user-select: none; + touch-action: none; +} + +/* Le celle riempiono la griglia in auto-placement (ordine row-major nel tsx); + quadrate come in GameScene, niente radius fuori scala token. */ +.cell { + width: var(--ed-cell); + height: var(--ed-cell); + padding: 0; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; + font-size: calc(var(--ed-cell) * 0.5); + transition: box-shadow var(--dur-fast) var(--ease-out); +} +.cell:hover { + box-shadow: inset 0 0 0 2px var(--ifm-color-primary); +} +.cellFloor { + background: var(--ed-floor); +} +.cellWall { + background: var(--ed-wall); +} + +.mark { + pointer-events: none; + line-height: 1; +} +.markHero { + color: var(--ed-hero); + display: inline-flex; +} +.markGoal { + color: var(--ed-goal); + position: absolute; + inset: 0; + margin: auto; + opacity: 0.85; +} +.markEnemy { + color: var(--ed-enemy); + position: absolute; + right: 2px; + bottom: 2px; + font-size: calc(var(--ed-cell) * 0.42); +} +.markResource { + color: var(--ed-resource); + position: absolute; + left: 2px; + top: 2px; + font-size: calc(var(--ed-cell) * 0.42); +} + +/* Colori griglia (allineati a GameScene, override dark dedicato). */ +.board { + --ed-board-bg: #e9edf2; + --ed-floor: #f5f7fa; + --ed-wall: #40495b; + --ed-hero: var(--ifm-color-primary); + --ed-goal: #2f9e44; + --ed-enemy: #d6336c; + --ed-resource: #f08c00; +} +html[data-theme='dark'] .board { + --ed-board-bg: #14161c; + --ed-floor: #1e2230; + --ed-wall: #2b3245; + --ed-goal: #51cf66; + --ed-enemy: #f06595; + --ed-resource: #ffc078; +} + +/* Errori di validazione. */ +.errors { + margin: 0 0 0.9rem; + padding: 0.6rem 0.9rem 0.6rem 1.5rem; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--ifm-color-danger) 12%, transparent); + color: var(--ifm-color-danger-darker); + font-size: 0.85rem; +} +.errors li { + margin: 0.15rem 0; +} +html[data-theme='dark'] .errors { + color: var(--ifm-color-danger-lightest); +} + +/* Form metadati. */ +.fieldGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.78rem; + font-weight: 600; + color: var(--ifm-color-emphasis-700); +} +.field input, +.field select { + font-weight: 400; +} + +.row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} +.rowId { + font-family: var(--ifm-font-family-monospace); + font-size: 0.78rem; + color: var(--ifm-color-emphasis-600); + min-width: 5.5rem; +} +.grow { + flex: 1; +} +.numSmall { + width: 4.5rem; +} + +/* Input comuni. */ +.editor input[type='text'], +.editor input[type='number'], +.editor select, +.editor textarea { + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--radius-xs); + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + padding: 0.35rem 0.5rem; + font-size: 0.85rem; + font-family: inherit; +} +.editor input:focus, +.editor select:focus, +.editor textarea:focus { + outline: 2px solid var(--ifm-color-primary); + outline-offset: -1px; + border-color: transparent; +} + +.code { + width: 100%; + font-family: var(--ifm-font-family-monospace); + font-size: 0.8rem; + line-height: 1.45; + resize: vertical; + margin-bottom: 0.75rem; +} + +/* Bottoni azione. */ +.addBtn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + border-radius: var(--radius-sm); + padding: 0.3rem 0.65rem; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: + border-color var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out); +} +.addBtn:hover { + border-color: var(--ifm-color-primary); +} +.addBtn:active { + transform: scale(0.98); +} + +.iconBtn { + border: 1px solid transparent; + background: transparent; + color: var(--ifm-color-emphasis-600); + border-radius: var(--radius-xs); + padding: 0.3rem 0.45rem; + cursor: pointer; + transition: + color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} +.iconBtn:hover { + color: var(--ifm-color-danger); + background: color-mix(in srgb, var(--ifm-color-danger) 12%, transparent); +} + +.importError { + color: var(--ifm-color-danger); + font-size: 0.82rem; + margin: 0 0 0.5rem; +} diff --git a/src/pages/level-editor.tsx b/src/pages/level-editor.tsx new file mode 100644 index 0000000..2ecf207 --- /dev/null +++ b/src/pages/level-editor.tsx @@ -0,0 +1,1287 @@ +/** + * Editor di livelli PyQuest (spec step 17) — `/level-editor`. + * + * Strumento interno d'autore, non linkato in navbar e `noindex`: disegna un + * livello (palette + paint click&drag, resize griglia, form metadati), lo vede + * girare nell'anteprima live ``, e ne esporta il + * JSON da incollare in `static/pyquest//world.json`. + * + * Il modello interno (`Draft`) è comodo per l'editing (muri come array di bool, + * eroe/goal/entità come oggetti); `draftToLevel` lo proietta sullo schema + * `LevelDef` (speculare al world.json) e `levelToDraft` fa il verso inverso in + * import. La validazione ricalca le regole del plugin build-time + * (`plugins/pyquest/index.js`) così l'anteprima segnala in anticipo ciò che + * romperebbe la build. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Head from '@docusaurus/Head'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import Layout from '@theme/Layout'; +import Heading from '@theme/Heading'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faPenRuler, + faPlus, + faTrash, + faCopy, + faFileImport, + faRobot, +} from '@fortawesome/free-solid-svg-icons'; +import clsx from 'clsx'; +import type { CSSProperties } from 'react'; +import PyQuest from '@site/src/components/PyQuest'; +import { + ENEMY_KINDS, + RESOURCE_KINDS, + enemyVisual, + resourceVisual, + GOAL_ICON, +} from '@site/src/components/PyQuest/characters'; +import { FACING_DEG } from '@site/src/components/PyQuest/GameScene'; +import { copyToClipboard } from '@site/src/theme/PyRunner/clipboard'; +import type { + EnemyBehavior, + EnemyDef, + Facing, + LevelDef, + ResourceDef, + WinCond, +} from '@site/src/components/PyQuest/types'; +import styles from './level-editor.module.css'; + +// --- Costanti d'autore -------------------------------------------------------- + +type Tool = 'wall' | 'floor' | 'hero' | 'goal' | 'enemy' | 'resource'; + +const FACINGS: Facing[] = ['north', 'east', 'south', 'west']; +const FACING_LABEL: Record = { + north: 'nord', + east: 'est', + south: 'sud', + west: 'ovest', +}; +const BEHAVIORS: EnemyBehavior[] = ['static', 'melee']; +const WIN_TYPES: WinCond['type'][] = ['reach', 'collect', 'defeat']; + +const MIN_SIZE = 3; +const MAX_SIZE = 15; +const DEFAULT_SIZE = 7; +/** Tetto sui punti vita: la scena renderizza un'icona per PV (cuori/pip). */ +const MAX_HP = 99; + +const PALETTE: { tool: Tool; label: string }[] = [ + { tool: 'wall', label: 'Muro' }, + { tool: 'floor', label: 'Pavimento' }, + { tool: 'hero', label: 'Eroe' }, + { tool: 'goal', label: 'Bandiera' }, + { tool: 'enemy', label: 'Nemico' }, + { tool: 'resource', label: 'Risorsa' }, +]; + +// --- Modello di editing ------------------------------------------------------- + +interface Draft { + id: string; + title: string; + width: number; + height: number; + /** Muri: `walls[y * width + x]`. */ + walls: boolean[]; + hero: { x: number; y: number; facing: Facing; hp: number }; + goal: { x: number; y: number } | null; + enemies: EnemyDef[]; + resources: ResourceDef[]; + win: WinCond[]; + hints: string[]; + par: number; + maxSteps: number | null; + starterCode: string; + solution: string; +} + +/** Stanza vuota bordata di muri, eroe in alto a sinistra, bandiera in fondo. */ +function emptyDraft(width: number, height: number): Draft { + const walls: boolean[] = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const border = x === 0 || y === 0 || x === width - 1 || y === height - 1; + walls.push(border); + } + } + return { + id: 'nuovo-livello', + title: 'Nuovo livello', + width, + height, + walls, + hero: { x: 1, y: 1, facing: 'east', hp: 3 }, + goal: { x: width - 2, y: height - 2 }, + enemies: [], + resources: [], + win: [{ type: 'reach' }], + hints: ['Primo suggerimento.'], + par: 1, + maxSteps: null, + starterCode: '# Scrivi qui il codice iniziale che vede lo studente.\n', + solution: '', + }; +} + +function allIds(d: Draft): Set { + return new Set([ + ...d.enemies.map((e) => e.id), + ...d.resources.map((r) => r.id), + ]); +} + +/** Primo `prefix + n` (n ≥ 1) non ancora usato. */ +function freeId(prefix: string, used: Set): string { + let n = 1; + while (used.has(`${prefix}${n}`)) n++; + return `${prefix}${n}`; +} + +/** Copia di `arr` con l'elemento in posizione `i` sostituito da `next`. */ +function replaceAt(arr: T[], i: number, next: T): T[] { + return arr.map((x, j) => (j === i ? next : x)); +} + +/** Copia di `arr` senza l'elemento in posizione `i`. */ +function removeAt(arr: T[], i: number): T[] { + return arr.filter((_, j) => j !== i); +} + +// --- Proiezioni Draft ↔ LevelDef --------------------------------------------- + +function draftToLevel(d: Draft): LevelDef { + const rows: string[] = []; + for (let y = 0; y < d.height; y++) { + let row = ''; + for (let x = 0; x < d.width; x++) { + row += d.walls[y * d.width + x] ? '#' : '.'; + } + rows.push(row); + } + const level: LevelDef = { + id: d.id, + title: d.title, + grid: { legend: { '#': 'wall', '.': 'floor' }, rows }, + hero: d.hero, + win: d.win, + starterCode: d.starterCode, + hints: d.hints, + par: d.par, + }; + if (d.goal) level.goal = d.goal; + if (d.enemies.length) level.enemies = d.enemies; + if (d.resources.length) level.resources = d.resources; + if (d.solution.trim()) level.solution = d.solution; + if (d.maxSteps != null) level.maxSteps = d.maxSteps; + return level; +} + +/** + * Import: proietta un `LevelDef` (già filtrato da `handleImport`) sul modello. + * I campi stringa e numerici vengono coercitivamente normalizzati: un JSON + * scritto a mano può avere tipi sbagliati o campi mancanti, e un draft con + * `undefined` dove i form si aspettano un valore renderebbe gli input + * uncontrolled (o farebbe crashare la pagina) invece di mostrare un errore + * di validazione. I fallback sono valori invalidi apposta ('' e 0/-1): la + * validazione li segnala, senza riparare il livello di nascosto. + */ +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +function num(v: unknown, fallback: number): number { + return typeof v === 'number' ? v : fallback; +} + +function levelToDraft(level: LevelDef): Draft { + const rows = level.grid.rows; + const height = rows.length; + const width = rows[0].length; + const wallChars = new Set( + Object.entries(level.grid.legend) + .filter(([, role]) => role === 'wall') + .map(([ch]) => ch), + ); + const walls: boolean[] = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + walls.push(wallChars.has(rows[y][x])); + } + } + return { + id: str(level.id), + title: str(level.title), + width, + height, + walls, + hero: { + x: num(level.hero.x, -1), + y: num(level.hero.y, -1), + facing: FACINGS.includes(level.hero.facing) ? level.hero.facing : 'east', + hp: num(level.hero.hp ?? 3, 0), + }, + goal: level.goal ?? null, + enemies: (Array.isArray(level.enemies) ? level.enemies : []).map((e) => ({ + ...e, + id: str(e.id), + kind: str(e.kind), + x: num(e.x, -1), + y: num(e.y, -1), + hp: num(e.hp, 0), + })), + resources: (Array.isArray(level.resources) ? level.resources : []).map( + (r) => ({ + ...r, + id: str(r.id), + kind: str(r.kind), + x: num(r.x, -1), + y: num(r.y, -1), + }), + ), + win: level.win.map((c) => + c.type === 'collect' + ? { ...c, kind: str(c.kind), qty: num(c.qty, 0) } + : c.type === 'defeat' + ? { + ...c, + kind: c.kind === undefined ? undefined : str(c.kind), + count: c.count === undefined ? undefined : num(c.count, 0), + } + : c, + ), + hints: level.hints, + par: num(level.par, 0), + maxSteps: level.maxSteps == null ? null : num(level.maxSteps, 0), + starterCode: str(level.starterCode), + solution: str(level.solution), + }; +} + +// --- Validazione (ricalca plugins/pyquest/index.js) --------------------------- + +const FORBIDDEN = /['"\\]/; +// eslint-disable-next-line no-control-regex +const CONTROL = /[\u0000-\u001f]/; + +function safeId(v: unknown): boolean { + return ( + typeof v === 'string' && + v.length > 0 && + !FORBIDDEN.test(v) && + !CONTROL.test(v) + ); +} + +/** Intero ≥ min: gli input `type="number"` lasciano passare anche i decimali. */ +function intAtLeast(v: unknown, min: number): boolean { + return typeof v === 'number' && Number.isInteger(v) && v >= min; +} + +/** Punti vita sensati: intero in [1, MAX_HP] (la scena disegna un'icona a PV). */ +function validHp(v: unknown): boolean { + return intAtLeast(v, 1) && (v as number) <= MAX_HP; +} + +/** Elenco di problemi che romperebbero l'import in world.json (vuoto = ok). */ +function validateDraft(d: Draft): string[] { + const errs: string[] = []; + // Cella valida per un'entità: dentro la griglia e non su un muro (il plugin + // fa lo stesso check con assertOnFloor). Raggiungibile via import: il paint + // non permette di creare queste configurazioni. + const onFloor = (x: number, y: number) => + Number.isInteger(x) && + Number.isInteger(y) && + x >= 0 && + y >= 0 && + x < d.width && + y < d.height && + !d.walls[y * d.width + x]; + + if (!safeId(d.id)) { + errs.push( + 'L’id deve essere non vuoto e senza apici, virgolette o backslash.', + ); + } + if (!d.title.trim()) errs.push('Il titolo è obbligatorio.'); + + if (!onFloor(d.hero.x, d.hero.y)) { + errs.push('L’eroe è fuori dalla griglia o su una cella muro.'); + } + if (!validHp(d.hero.hp)) { + errs.push( + `I punti vita dell’eroe devono essere un intero tra 1 e ${MAX_HP}.`, + ); + } + + if (d.goal && !onFloor(d.goal.x, d.goal.y)) { + errs.push('La bandiera è fuori dalla griglia o su una cella muro.'); + } + + if (d.win.length === 0) { + errs.push('Serve almeno una condizione di vittoria.'); + } + for (const cond of d.win) { + if (cond.type === 'reach' && !d.goal) { + errs.push('Vittoria «raggiungi» ma il livello non ha una bandiera.'); + } + if (cond.type === 'collect') { + if (!safeId(cond.kind)) + errs.push('Vittoria «raccogli»: tipo risorsa non valido.'); + if (!intAtLeast(cond.qty, 1)) { + errs.push( + 'Vittoria «raccogli»: la quantità deve essere un intero ≥ 1.', + ); + } + } + if (cond.type === 'defeat') { + if (cond.kind !== undefined && !safeId(cond.kind)) { + errs.push('Vittoria «sconfiggi»: tipo nemico non valido.'); + } + if (cond.count !== undefined && !intAtLeast(cond.count, 1)) { + errs.push( + 'Vittoria «sconfiggi»: il conteggio deve essere un intero ≥ 1.', + ); + } + } + } + + const ids = new Set(); + for (const e of d.enemies) { + if (!safeId(e.id) || ids.has(e.id)) + errs.push(`Nemico con id non valido o duplicato: «${e.id}».`); + ids.add(e.id); + if (!safeId(e.kind)) errs.push(`Nemico «${e.id}»: tipo non valido.`); + if (!onFloor(e.x, e.y)) + errs.push(`Nemico «${e.id}»: fuori dalla griglia o su una cella muro.`); + if (!validHp(e.hp)) + errs.push( + `Nemico «${e.id}»: i punti vita devono essere un intero tra 1 e ${MAX_HP}.`, + ); + if (!BEHAVIORS.includes(e.behavior)) + errs.push(`Nemico «${e.id}»: comportamento non valido (static|melee).`); + } + for (const r of d.resources) { + if (!safeId(r.id) || ids.has(r.id)) + errs.push(`Risorsa con id non valido o duplicato: «${r.id}».`); + ids.add(r.id); + if (!safeId(r.kind)) errs.push(`Risorsa «${r.id}»: tipo non valido.`); + if (!onFloor(r.x, r.y)) + errs.push(`Risorsa «${r.id}»: fuori dalla griglia o su una cella muro.`); + } + + if ( + d.hints.length < 1 || + d.hints.length > 3 || + d.hints.some((h) => typeof h !== 'string' || !h.trim()) + ) { + errs.push('Servono da uno a tre suggerimenti, tutti non vuoti.'); + } + if (!intAtLeast(d.par, 1)) { + errs.push('Il par deve essere un intero ≥ 1.'); + } + if (d.maxSteps != null && !intAtLeast(d.maxSteps, 1)) { + errs.push('maxSteps deve essere un intero ≥ 1.'); + } + return errs; +} + +// --- Griglia editabile -------------------------------------------------------- + +interface GridStyle extends CSSProperties { + '--ed-cols'?: number; + '--ed-rows'?: number; +} + +function EditableGrid({ + draft, + tool, + paint, +}: { + draft: Draft; + tool: Tool; + paint: (x: number, y: number) => void; +}) { + const painting = useRef(false); + + useEffect(() => { + const up = () => { + painting.current = false; + }; + window.addEventListener('mouseup', up); + return () => window.removeEventListener('mouseup', up); + }, []); + + const enemyAt = (x: number, y: number) => + draft.enemies.find((e) => e.x === x && e.y === y); + const resourceAt = (x: number, y: number) => + draft.resources.find((r) => r.x === x && r.y === y); + + const boardStyle: GridStyle = { + '--ed-cols': draft.width, + '--ed-rows': draft.height, + }; + + // Celle in ordine row-major: l'auto-placement della griglia CSS le posiziona + // da solo (niente coordinate inline per cella; l'ordine è load-bearing). + const cells = []; + for (let y = 0; y < draft.height; y++) { + for (let x = 0; x < draft.width; x++) { + const wall = draft.walls[y * draft.width + x]; + const isHero = draft.hero.x === x && draft.hero.y === y; + const isGoal = draft.goal?.x === x && draft.goal?.y === y; + const enemy = enemyAt(x, y); + const resource = resourceAt(x, y); + cells.push( + , + ); + } + } + + return ( +
+ {cells} +
+ ); +} + +// --- Editor completo (client-only) ------------------------------------------- + +function LevelEditorInner() { + const [draft, setDraft] = useState(() => + emptyDraft(DEFAULT_SIZE, DEFAULT_SIZE), + ); + const [tool, setTool] = useState('wall'); + // Testo grezzo degli input dimensione: applicare il resize (con clamp) a ogni + // keystroke distruggerebbe la griglia digitando — «12» passa per «1», e il + // campo svuotato varrebbe 0 → troncamento a MIN_SIZE con perdita di muri ed + // entità. Il resize scatta solo quando il testo è un intero nel range. + const [sizeText, setSizeText] = useState({ + width: String(DEFAULT_SIZE), + height: String(DEFAULT_SIZE), + }); + const [importText, setImportText] = useState(''); + const [importError, setImportError] = useState(null); + const [copied, setCopied] = useState(false); + + const level = useMemo(() => draftToLevel(draft), [draft]); + const errors = useMemo(() => validateDraft(draft), [draft]); + const exportJson = useMemo(() => JSON.stringify(level, null, 2), [level]); + + const paint = useCallback( + (x: number, y: number) => { + setDraft((d) => { + const i = y * d.width + x; + const isWall = d.walls[i]; + switch (tool) { + case 'wall': { + if (d.hero.x === x && d.hero.y === y) return d; // niente muro sull’eroe + const hasGoal = d.goal !== null && d.goal.x === x && d.goal.y === y; + const hasEntity = + d.enemies.some((e) => e.x === x && e.y === y) || + d.resources.some((r) => r.x === x && r.y === y); + // Già muro e niente da spazzare via: no-op (il trascinamento su + // muri esistenti non deve rifare draft → anteprima a ogni cella). + if (isWall && !hasGoal && !hasEntity) return d; + const walls = [...d.walls]; + walls[i] = true; + return { + ...d, + walls, + goal: hasGoal ? null : d.goal, + enemies: d.enemies.filter((e) => !(e.x === x && e.y === y)), + resources: d.resources.filter((r) => !(r.x === x && r.y === y)), + }; + } + case 'floor': { + if (!isWall) return d; + const walls = [...d.walls]; + walls[i] = false; + return { ...d, walls }; + } + case 'hero': { + if (isWall) return d; + return { ...d, hero: { ...d.hero, x, y } }; + } + case 'goal': { + if (isWall) return d; + if (d.goal && d.goal.x === x && d.goal.y === y) + return { ...d, goal: null }; + return { ...d, goal: { x, y } }; + } + case 'enemy': { + if (isWall || (d.hero.x === x && d.hero.y === y)) return d; + const existing = d.enemies.find((e) => e.x === x && e.y === y); + if (existing) + return { ...d, enemies: d.enemies.filter((e) => e !== existing) }; + const id = freeId('e', allIds(d)); + return { + ...d, + enemies: [ + ...d.enemies, + { id, kind: 'bug', x, y, hp: 2, behavior: 'melee' }, + ], + }; + } + case 'resource': { + if (isWall || (d.hero.x === x && d.hero.y === y)) return d; + const existing = d.resources.find((r) => r.x === x && r.y === y); + if (existing) + return { + ...d, + resources: d.resources.filter((r) => r !== existing), + }; + const id = freeId('r', allIds(d)); + return { + ...d, + resources: [...d.resources, { id, kind: 'gem', x, y }], + }; + } + default: + return d; + } + }); + }, + [tool], + ); + + const resize = useCallback((dim: 'width' | 'height', raw: string) => { + setSizeText((s) => ({ ...s, [dim]: raw })); + const size = Number(raw); + if (!Number.isInteger(size) || size < MIN_SIZE || size > MAX_SIZE) return; + setDraft((d) => { + const width = dim === 'width' ? size : d.width; + const height = dim === 'height' ? size : d.height; + const walls: boolean[] = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + walls.push( + x < d.width && y < d.height ? d.walls[y * d.width + x] : false, + ); + } + } + const inB = (p: { x: number; y: number }) => p.x < width && p.y < height; + const hero = inB(d.hero) + ? d.hero + : { + ...d.hero, + x: Math.min(d.hero.x, width - 1), + y: Math.min(d.hero.y, height - 1), + }; + return { + ...d, + width, + height, + walls, + hero, + goal: d.goal && inB(d.goal) ? d.goal : null, + enemies: d.enemies.filter(inB), + resources: d.resources.filter(inB), + }; + }); + }, []); + + const patch = useCallback((p: Partial) => { + setDraft((d) => ({ ...d, ...p })); + }, []); + + const handleImport = useCallback(() => { + setImportError(null); + let parsed: unknown; + try { + parsed = JSON.parse(importText); + } catch { + setImportError('JSON non valido.'); + return; + } + const lvl = parsed as LevelDef; + if ( + !lvl || + typeof lvl !== 'object' || + !lvl.grid || + !Array.isArray(lvl.grid.rows) || + lvl.grid.rows.length === 0 || + lvl.grid.rows.some((row) => typeof row !== 'string') || + !lvl.hero || + !Array.isArray(lvl.win) || + !Array.isArray(lvl.hints) + ) { + setImportError( + 'Non sembra un livello PyQuest (mancano grid/hero/win/hints).', + ); + return; + } + // Il plugin rifiuta le griglie non rettangolari; qui il modello a walls[] + // le «riparerebbe» in silenzio (righe corte → pavimento, righe lunghe + // troncate), riscrivendo la geometria — meglio rifiutare anche noi. + if (lvl.grid.rows.some((row) => row.length !== lvl.grid.rows[0].length)) { + setImportError( + 'Griglia non rettangolare: tutte le righe devono avere la stessa lunghezza.', + ); + return; + } + // Il form può rendere solo i tipi che conosce: win e hints malformati + // farebbero crashare il render, meglio rifiutarli con un messaggio. + if ( + lvl.win.some( + (c) => + !c || + typeof c !== 'object' || + !(WIN_TYPES as string[]).includes(c.type), + ) + ) { + setImportError( + 'Condizioni di vittoria non riconosciute (attesi reach/collect/defeat).', + ); + return; + } + if (lvl.hints.some((h) => typeof h !== 'string')) { + setImportError('I suggerimenti devono essere stringhe.'); + return; + } + if ( + [ + ...(Array.isArray(lvl.enemies) ? lvl.enemies : []), + ...(Array.isArray(lvl.resources) ? lvl.resources : []), + ].some((e) => !e || typeof e !== 'object') + ) { + setImportError('Nemici o risorse malformati (attesi oggetti).'); + return; + } + try { + const next = levelToDraft(lvl); + setDraft(next); + setSizeText({ width: String(next.width), height: String(next.height) }); + setImportText(''); + } catch { + setImportError('Livello illeggibile: controlla la struttura.'); + } + }, [importText]); + + const handleCopy = useCallback(() => { + copyToClipboard(exportJson) + .then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }) + .catch(() => setCopied(false)); + }, [exportJson]); + + return ( +
+ {/* Colonna sinistra: disegno + anteprima */} +
+
+
+
+ {PALETTE.map((p) => ( + + ))} +
+
+ + +
+
+

+ Seleziona uno strumento e disegna sulla griglia (muro e pavimento + supportano il trascinamento). Con «Nemico» o «Risorsa» clicca una + cella occupata per rimuoverla; con «Bandiera» clicca la bandiera per + toglierla. +

+ +
+ +
+ + Anteprima + + {errors.length > 0 && ( +
    + {errors.map((e) => ( +
  • {e}
  • + ))} +
+ )} + {/* Anteprima solo su draft valido: un livello che la validazione + rifiuta salterebbe le guardie che il plugin garantisce a build + time (quoting delle stringhe iniettate in Python, geometria). + PyQuest assorbe live i cambi di levelData e starterCode. */} + {errors.length === 0 ? ( + + ) : ( +

+ Correggi gli errori qui sopra per vedere l’anteprima. +

+ )} +
+
+ + {/* Colonna destra: metadati + import/export */} +
+
+ + Metadati + +
+ + + + + + +
+
+ +
+
+ + Vittoria + + +
+ {draft.win.map((cond, i) => ( +
+ + {cond.type === 'collect' && ( + <> + + + patch({ + win: replaceAt(draft.win, i, { + ...cond, + qty: Number(e.target.value) || 1, + }), + }) + } + /> + + )} + {cond.type === 'defeat' && ( + <> + + + patch({ + win: replaceAt(draft.win, i, { + ...cond, + count: e.target.value + ? Number(e.target.value) + : undefined, + }), + }) + } + /> + + )} + +
+ ))} +
+ + {draft.enemies.length > 0 && ( +
+ + Nemici + + {draft.enemies.map((e, i) => ( +
+ + {e.id} ({e.x},{e.y}) + + + + patch({ + enemies: replaceAt(draft.enemies, i, { + ...e, + hp: Math.min(MAX_HP, Number(ev.target.value) || 1), + }), + }) + } + /> + + +
+ ))} +
+ )} + + {draft.resources.length > 0 && ( +
+ + Risorse + + {draft.resources.map((r, i) => ( +
+ + {r.id} ({r.x},{r.y}) + + + +
+ ))} +
+ )} + +
+
+ + Suggerimenti (1–3) + + {draft.hints.length < 3 && ( + + )} +
+ {draft.hints.map((h, i) => ( +
+ + patch({ hints: replaceAt(draft.hints, i, e.target.value) }) + } + /> + {draft.hints.length > 1 && ( + + )} +
+ ))} +
+ +
+ + Codice iniziale + +