From b03bb404407ba4d411b21742daecf7041212751e Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Thu, 2 Jul 2026 09:03:36 +0200 Subject: [PATCH 01/11] feat(pyquest): motore trace-based (nucleo movimento) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 1 (M1), step 1-4 della spec PyQuest. - config.py: espone Config.NODE_ID (node_id grezzo) in set_id(), così pyquest.py può fare notify() sullo stesso div che bryBridge ascolta. - bryBridge.ts: RunOptions.onCustom + BryNotifyDetail.type ora string; nel switch un ramo default inoltra gli eventi fuori protocollo (D3). Retrocompatibile: PyRunner/SQLRunner invariati. - pyquest.py: modello logico puro (World/Hero), _load_level che riassegna il global _world (no contaminazione tra run del modulo cacheato), move/turn, sensori is_blocked/on_goal, action_guard + StepLimitError, contatore sensori, win spec «reach». Emissione eventi come payload JSON (D4). - types.ts: tipi normativi della trace (GameEvent/SceneState/LogLine) + schema dati LevelDef/WorldDef. - runLevel.ts: compone il preCode (from pyquest import * + _load_level + shadow di input) con JSON compatto spogliato dei campi testuali (D2/D7); riparsa gli eventi via onCustom. Logica del motore validata con CPython (stub di config/py_back_trace): move/turn/sensori, vittoria reach, no-op post-vittoria, assenza di contaminazione tra run, step_limit. Bridge Brython/DOM riusa l'infrastruttura PyRunner esistente. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pv1PzGn4LtwKFku39y15V4 --- src/components/PyQuest/runLevel.ts | 91 +++++++++++++ src/components/PyQuest/types.ts | 120 +++++++++++++++++ src/theme/PyRunner/bryBridge.ts | 14 +- static/bry-libs/config.py | 4 + static/bry-libs/pyquest.py | 209 +++++++++++++++++++++++++++++ 5 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 src/components/PyQuest/runLevel.ts create mode 100644 src/components/PyQuest/types.ts create mode 100644 static/bry-libs/pyquest.py diff --git a/src/components/PyQuest/runLevel.ts b/src/components/PyQuest/runLevel.ts new file mode 100644 index 0000000..1f128be --- /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'; + +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/theme/PyRunner/bryBridge.ts b/src/theme/PyRunner/bryBridge.ts index 50b3e62..3db2976 100644 --- a/src/theme/PyRunner/bryBridge.ts +++ b/src/theme/PyRunner/bryBridge.ts @@ -25,10 +25,18 @@ export interface RunOptions { onLog: (kind: LogKind, text: string) => void; onDone: (durationMs: number) => void; onError?: (err: Error) => void; + /** + * Eventi `bry_notify` con un `type` fuori dal protocollo standard + * (start/done/stdout/stderr). PyQuest lo usa per gli eventi `game_event` + * emessi da `pyquest.py`; il payload applicativo viaggia in `detail.payload` + * come stringa JSON (vedi D4 nella spec). + */ + onCustom?: (detail: { type: string } & Record) => void; } interface BryNotifyDetail { - type: 'start' | 'done' | 'stdout' | 'stderr'; + // `string` (non un'unione chiusa) per accogliere i type applicativi (PyQuest). + type: string; output?: string; time?: number; } @@ -90,6 +98,7 @@ export function runPython(code: string, opts: RunOptions): () => void { onLog, onDone, onError, + onCustom, } = opts; // Difensivo: `codeId` viene interpolato in un sorgente Python che poi @@ -117,6 +126,9 @@ export function runPython(code: string, opts: RunOptions): () => void { case 'stderr': if (detail.output) onLog(detail.type, detail.output); break; + default: + onCustom?.(detail as never); + break; } }; diff --git a/static/bry-libs/config.py b/static/bry-libs/config.py index a1e62cd..d73670f 100644 --- a/static/bry-libs/config.py +++ b/static/bry-libs/config.py @@ -5,9 +5,13 @@ class Config(): GRAPHICS_ID = f'id_graphics' TURTLE_SVG_CONTAINER = f'id_svg' OUTPUT_DIV = f'id_brython_result' + # node_id grezzo: serve a PyQuest per fare notify() sullo stesso div che + # bryBridge ascolta (py_). Gli altri campi sono id DOM derivati. + NODE_ID = 'id' @staticmethod def set_id(node_id): + Config.NODE_ID = node_id Config.BRYTHON_COMMUNICATOR = f'py_{node_id}' Config.CANVAS_ID = f'{node_id}_canvas' Config.GRAPHICS_ID = f'{node_id}_graphics' diff --git a/static/bry-libs/pyquest.py b/static/bry-libs/pyquest.py new file mode 100644 index 0000000..dd33d4a --- /dev/null +++ b/static/bry-libs/pyquest.py @@ -0,0 +1,209 @@ +# pyquest.py — modello logico dei mini-giochi a griglia (PyQuest). +# +# Motore trace-based (spec D1): il codice studente viene eseguito davvero, per +# intero e sincrono, contro questo modello in puro Python. Ogni azione aggiorna +# il modello ED emette subito un evento `game_event` via notify(); React (lato +# TS) accumula la trace e la anima dopo. I sensori leggono lo stato corrente del +# modello (aggiornato durante l'esecuzione vera), quindi i cicli condizionati +# funzionano. +# +# NB (step 6, sys.settrace): decisione ancora da prendere — vedi task PyQuest. +# Finché non è documentata qui, il residuo `while True: pass` senza chiamate API +# blocca il main thread come oggi in PyRunner (mitigato da step_count e +# sensor_count per i loop che *chiamano* qualcosa). + +import json +from config import Config # type: ignore +from py_back_trace import notify # type: ignore + +__all__ = [ + '_load_level', + 'move', 'turn_left', 'turn_right', + 'is_blocked', 'on_goal', +] + +# --- Costanti ----------------------------------------------------------------- + +MAX_STEPS_DEFAULT = 500 +MAX_SENSOR_READS = 100_000 + +# y cresce verso il basso; facing in north|east|south|west. +DIRS = { + 'north': (0, -1), + 'east': (1, 0), + 'south': (0, 1), + 'west': (-1, 0), +} + +_TURN_LEFT = {'north': 'west', 'west': 'south', 'south': 'east', 'east': 'north'} +_TURN_RIGHT = {'north': 'east', 'east': 'south', 'south': 'west', 'west': 'north'} + + +# --- Eccezioni di fine partita ------------------------------------------------ + +class StepLimitError(Exception): + """Superato il tetto di azioni (o di letture sensore): ciclo non terminante.""" + pass + + +# --- Modello ------------------------------------------------------------------ + +class Hero: + def __init__(self, x, y, facing, hp): + self.x = x + self.y = y + self.facing = facing + self.hp = hp + self.inventory = {} + + +class World: + def __init__(self, data): + grid = data['grid'] + legend = grid['legend'] + rows = grid['rows'] + self.height = len(rows) + self.width = len(rows[0]) if rows else 0 + self.walls = set() + for y, row in enumerate(rows): + for x, ch in enumerate(row): + if legend.get(ch) == 'wall': + self.walls.add((x, y)) + + h = data['hero'] + self.hero = Hero(h['x'], h['y'], h.get('facing', 'east'), h.get('hp', 3)) + + g = data.get('goal') + self.goal = (g['x'], g['y']) if g else None + + self.win_spec = data.get('win', []) + self.max_steps = data.get('maxSteps', MAX_STEPS_DEFAULT) + + self.step_count = 0 + self.sensor_count = 0 + self.won = False + self.dead = False + + def is_walkable(self, x, y): + if x < 0 or y < 0 or x >= self.width or y >= self.height: + return False + if (x, y) in self.walls: + return False + return True + + def tick(self): + # I nemici si muovono qui (step 13). Nel nucleo movimento è un no-op. + pass + + +_world = None # riassegnato a ogni _load_level → nessuna contaminazione tra run + + +def _load_level(json_str): + global _world + _world = World(json.loads(json_str)) + + +# --- Emissione eventi (D4: payload = stringa JSON) ---------------------------- + +def _emit(ev): + notify(Config.NODE_ID, {'type': 'game_event', 'payload': json.dumps(ev)}) + + +# --- Guardie di sicurezza ----------------------------------------------------- + +def _action_guard(): + _world.step_count += 1 + if _world.step_count > _world.max_steps: + _emit({'t': 'step_limit'}) + raise StepLimitError( + 'Hai superato le %d mosse: forse un ciclo non si ferma mai. ' + 'Controlla la condizione del tuo `while`.' % _world.max_steps + ) + + +def _sensor_guard(): + _world.sensor_count += 1 + if _world.sensor_count > MAX_SENSOR_READS: + _emit({'t': 'step_limit'}) + raise StepLimitError( + 'Hai interrogato i sensori troppe volte senza agire: ' + 'probabilmente un `while` gira a vuoto. Fai muovere il tuo eroe.' + ) + + +# --- Ciclo del mondo ---------------------------------------------------------- + +def _after_action(): + _world.tick() + _check_win() + + +def _cond_met(cond): + t = cond.get('type') + if t == 'reach': + return _world.goal is not None and (_world.hero.x, _world.hero.y) == _world.goal + # collect / defeat: implementati allo step 13. Finché non lo sono, la + # condizione è considerata NON soddisfatta (mai vittoria fantasma). + return False + + +def _check_win(): + if _world.won: + return + if not _world.win_spec: + return + for cond in _world.win_spec: + if not _cond_met(cond): + return + _world.won = True + _emit({'t': 'win'}) + + +# --- API studente: azioni ----------------------------------------------------- + +def move(): + if _world.won: + return + _action_guard() + hero = _world.hero + dx, dy = DIRS[hero.facing] + nx, ny = hero.x + dx, hero.y + dy + if _world.is_walkable(nx, ny): + hero.x, hero.y = nx, ny + _emit({'t': 'move', 'x': nx, 'y': ny, 'f': hero.facing}) + else: + _emit({'t': 'bump', 'x': nx, 'y': ny}) + _after_action() + + +def turn_left(): + if _world.won: + return + _action_guard() + _world.hero.facing = _TURN_LEFT[_world.hero.facing] + _emit({'t': 'turn', 'f': _world.hero.facing}) + _after_action() + + +def turn_right(): + if _world.won: + return + _action_guard() + _world.hero.facing = _TURN_RIGHT[_world.hero.facing] + _emit({'t': 'turn', 'f': _world.hero.facing}) + _after_action() + + +# --- API studente: sensori (gratuiti, senza tick) ----------------------------- + +def is_blocked(): + _sensor_guard() + hero = _world.hero + dx, dy = DIRS[hero.facing] + return not _world.is_walkable(hero.x + dx, hero.y + dy) + + +def on_goal(): + _sensor_guard() + return _world.goal is not None and (_world.hero.x, _world.hero.y) == _world.goal From 61048796cab723a3ae1f1a0b01de392c39494d47 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Thu, 2 Jul 2026 09:03:46 +0200 Subject: [PATCH 02/11] feat(pyquest): harness di sviluppo del motore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 1 (M1), step 5 della spec PyQuest. - index.tsx: prima versione debug del componente PyQuest — editor riusato da PyRunner + bottone Esegui + dump grezzo degli eventi e della console. Contatore di modulo per il suffisso del codeId (D12), così due istanze sulla stessa pagina restano indipendenti. Verrà riscritto allo step 12 (renderer + player). - pyquest-test.mdx: pagina unlisted /docs/pyquest-test con due harness e la checklist di verifica manuale (movimento, indipendenza, errore, vittoria). - MDXComponents.tsx: registra tra i componenti MDX globali. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pv1PzGn4LtwKFku39y15V4 --- docs/pyquest-test.mdx | 39 +++++ src/components/PyQuest/index.tsx | 245 +++++++++++++++++++++++++++++++ src/theme/MDXComponents.tsx | 2 + 3 files changed, 286 insertions(+) create mode 100644 docs/pyquest-test.mdx create mode 100644 src/components/PyQuest/index.tsx diff --git a/docs/pyquest-test.mdx b/docs/pyquest-test.mdx new file mode 100644 index 0000000..7c4f46c --- /dev/null +++ b/docs/pyquest-test.mdx @@ -0,0 +1,39 @@ +--- +unlisted: true +title: PyQuest — pagina di test +description: Harness di verifica del motore PyQuest (trace-based, mini-giochi a griglia) +--- + +# PyQuest — sandbox di test del motore + +Pagina temporanea per validare il **motore** di PyQuest (fase 1). Non è linkata +dalla sidebar (`unlisted: true`): la raggiungi solo via URL `/docs/pyquest-test`. +Qui il componente è in versione **harness**: editor + bottone Esegui + dump grezzo +degli eventi emessi dal motore Python. Renderer e player arrivano nella fase 2. + +Il livello di default è una griglia 5×5 con Byte in `(1, 1)` rivolto a est e un +muro a due celle di distanza. + +## 1. Movimento base + +Digita `for _ in range(3): move()` e premi Esegui: attesi `move`, `move`, `bump` +(la terza mossa sbatte contro il muro). + + + +## 2. Verifica indipendenza tra istanze + +Due harness sullo stesso livello sulla stessa pagina: devono avere `codeId` +diversi e trace indipendenti (esegui l'uno, l'altro resta fermo). + + + +## Note per la verifica (checklist step 5) + +- **a)** `for _ in range(3): move()` → `move, move, bump`. +- **b)** un errore a riga 2 (es. `move()` poi `boom`) → eventi fino a riga 1 + **più** un traceback su stderr che indica **riga 2**. +- **c)** due Esegui consecutivi producono trace identiche (nessuna + contaminazione tra run del modulo `pyquest` cacheato). +- Prova anche `while not on_goal(): move()` e la vittoria (`win`) quando Byte + raggiunge `(3, 3)` — serve girare e muoversi (`turn_right()` + `move()`). diff --git a/src/components/PyQuest/index.tsx b/src/components/PyQuest/index.tsx new file mode 100644 index 0000000..3fcadba --- /dev/null +++ b/src/components/PyQuest/index.tsx @@ -0,0 +1,245 @@ +/** + * PyQuest — mini-giochi a griglia con Python (spec: PyQuest). + * + * ⚠️ VERSIONE HARNESS (step 5). Questa è la prima versione "debug" del + * componente: editor riusato da PyRunner + bottone Esegui + dump grezzo degli + * eventi ricevuti dal motore. Niente renderer, niente player: quelli arrivano + * nella fase 2 (step 10-12), quando questo file verrà riscritto (step 12). + * Serve solo a validare il motore trace-based end-to-end. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import { usePluginData } from '@docusaurus/useGlobalData'; +import { Editor, type EditorHandle } from '@site/src/theme/PyRunner/Editor'; +import { ensureBrython, type BrythonConfig } from '@site/src/pyBoot'; +import { runLevel } from './runLevel'; +import type { GameEvent, LevelDef, LogLine } from './types'; + +export interface PyQuestProps { + /** Id del mondo nei global data del plugin `pyquest` (fase 2, step 7). */ + world?: string; + /** Id del livello dentro il mondo. */ + level?: string; + /** Livello passato inline (editor/test): bypassa la lookup su world/level. */ + levelData?: LevelDef; + /** Titolo mostrato sopra l'editor. */ + title?: string; +} + +interface PyRunnerGlobalData { + libUrl: string; + brython?: BrythonConfig; +} + +type RunStatus = 'idle' | 'running' | 'done' | 'error'; + +// Livello di fallback per l'harness: griglia 5×5, muro a 2 celle davanti a Byte +// così `for _ in range(3): move()` produce move, move, bump. +const DEFAULT_LEVEL: LevelDef = { + id: 'harness', + title: 'Harness 5×5', + grid: { + legend: { '#': 'wall', '.': 'floor' }, + rows: ['#####', '#...#', '#...#', '#...#', '#####'], + }, + hero: { x: 1, y: 1, facing: 'east', hp: 3 }, + goal: { x: 3, y: 3 }, + win: [{ type: 'reach' }], + starterCode: + '# Muovi Byte. Prova a cambiare il codice ed esegui.\nfor _ in range(3):\n move()\n', + hints: [], + par: 6, +}; + +// 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). + return `pyr_${(hash >>> 0).toString(36)}${n.toString(36)}`; +} + +function PyQuestInner(props: PyQuestProps) { + const data = usePluginData('pyrunner') as PyRunnerGlobalData | undefined; + const libUrl = data?.libUrl ?? ''; + const brython = data?.brython; + + const level = props.levelData ?? DEFAULT_LEVEL; + const seed = + props.world && props.level + ? `${props.world}/${props.level}` + : JSON.stringify(level); + + const instanceN = useRef(-1); + if (instanceN.current < 0) instanceN.current = idCounter++; + const codeId = useMemo(() => makeCodeId(seed, instanceN.current), [seed]); + + const [events, setEvents] = useState([]); + const [logs, setLogs] = useState([]); + const [status, setStatus] = useState('idle'); + + const editorRef = useRef(null); + const rootRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + + // Precarica Brython quando l'harness 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?.(), []); + + const handleRun = useCallback(() => { + const code = editorRef.current?.getCode() ?? level.starterCode; + setStatus('running'); + setEvents([]); + setLogs([]); + const collected: GameEvent[] = []; + cleanupRef.current?.(); + cleanupRef.current = runLevel({ + code, + level, + codeId, + libUrl, + brython, + onStart: () => { + collected.length = 0; + setEvents([]); + setLogs([]); + }, + onEvent: (ev) => { + collected.push(ev); + setEvents([...collected]); + }, + onLog: (kind, text) => { + setLogs((prev) => [...prev, { kind, text, atStep: collected.length }]); + if (kind === 'stderr') setStatus('error'); + }, + onDone: () => setStatus((s) => (s === 'error' ? s : 'done')), + onError: (err) => { + setStatus('error'); + setLogs((prev) => [ + ...prev, + { + kind: 'stderr', + text: `[PyQuest] ${err.message}\n`, + atStep: collected.length, + }, + ]); + }, + }); + }, [level, codeId, libUrl, brython]); + + if (!libUrl) { + return ( +
+ PyQuest (harness): plugin pyrunner non registrato — libUrl + mancante. +
+ ); + } + + const boxStyle: React.CSSProperties = { + border: '1px solid var(--at-border)', + borderRadius: 'var(--radius-m, 10px)', + padding: 8, + margin: '8px 0', + background: 'var(--at-bg-subtle)', + fontFamily: '"Monaspace Neon", ui-monospace, monospace', + fontSize: '0.85rem', + whiteSpace: 'pre-wrap', + maxHeight: 240, + overflow: 'auto', + }; + + return ( +
+
+ {props.title ?? level.title}{' '} + + — harness ({status}) + +
+
+ +
+ +
+ Eventi ({events.length}) +
+
+ {events.map((ev, i) => `${i}: ${JSON.stringify(ev)}`).join('\n') || '—'} +
+ {logs.length > 0 && ( + <> +
+ Console +
+
+ {logs.map((l) => `[${l.kind}@${l.atStep}] ${l.text}`).join('')} +
+ + )} +
+ ); +} + +export default function PyQuest(props: PyQuestProps) { + return ( + PyQuest…}> + {() => } + + ); +} diff --git a/src/theme/MDXComponents.tsx b/src/theme/MDXComponents.tsx index f6efe9d..dcdded5 100644 --- a/src/theme/MDXComponents.tsx +++ b/src/theme/MDXComponents.tsx @@ -16,6 +16,7 @@ import Quiz, { import PyRunner from '@site/src/theme/PyRunner'; import SQLRunner from '@site/src/theme/SQLRunner'; import Algorithm from '@site/src/components/Algorithm'; +import PyQuest from '@site/src/components/PyQuest'; import ExerciseLink from '@site/src/components/ExerciseLink'; import Exercise, { LessonMeta, Solution } from '@site/src/components/Exercise'; import AssignedExercises from '@site/src/components/AssignedExercises'; @@ -34,6 +35,7 @@ export default { PyRunner, SQLRunner, Algorithm, + PyQuest, ExerciseLink, Exercise, LessonMeta, From fa913b01338299ce534047fa376290f998ec85f4 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Thu, 2 Jul 2026 15:43:36 +0200 Subject: [PATCH 03/11] =?UTF-8?q?feat(pyquest):=20chiuso=20spike=20sys.set?= =?UTF-8?q?trace=20=E2=80=94=20guardia=20non=20attivata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test nel browser (Brython 3.12): settrace emette line events solo nei frame di funzione; il codice a livello modulo/exec (dove gira il codice studente) non genera line events, frame.f_trace è inefficace e il raise dal tracer non è contenibile in modo affidabile. Decisione documentata in testa a pyquest.py: rischio residuo `while True: pass` accettato, come oggi in PyRunner. Pagina spike temporanea eliminata. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V9dpQX4sKY6tJkTetJz3ho --- static/bry-libs/pyquest.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/static/bry-libs/pyquest.py b/static/bry-libs/pyquest.py index dd33d4a..d4860ff 100644 --- a/static/bry-libs/pyquest.py +++ b/static/bry-libs/pyquest.py @@ -7,10 +7,21 @@ # modello (aggiornato durante l'esecuzione vera), quindi i cicli condizionati # funzionano. # -# NB (step 6, sys.settrace): decisione ancora da prendere — vedi task PyQuest. -# Finché non è documentata qui, il residuo `while True: pass` senza chiamate API -# blocca il main thread come oggi in PyRunner (mitigato da step_count e -# sensor_count per i loop che *chiamano* qualcosa). +# Decisione step 6 (spike sys.settrace, 2026-07-02): guardia NON attivata, +# rischio residuo ACCETTATO. Misure in Brython 3.12 nel browser: +# - settrace con eventi 'line' funziona SOLO nei frame di funzione +# (400 005 eventi su un loop 200k in funzione; overhead ~20-27x, ~2-6 us/evento); +# - il codice a livello modulo — cioè il codice studente, exec-utato da +# brython_runner — NON genera line events (1 solo evento su loop da 300k +# iterazioni top-level), quindi la guardia non vedrebbe proprio il caso +# canonico `while True: pass`; +# - frame.f_trace sul frame corrente è accettato ma inefficace (0 eventi); +# - il raise dal tracer nei frame di funzione ha semantica rotta: +# sys.settrace(None) chiamato dentro il tracer non disattiva il tracing e +# l'eccezione riesplode durante le righe dell'handler, scavalcando l'except. +# Quindi: `while True: pass` senza chiamate API blocca il main thread come oggi +# in PyRunner; i loop che chiamano qualcosa sono mitigati da step_count e +# sensor_count qui sotto. import json from config import Config # type: ignore From 7f785344f0903861b7f50568595d503737926933 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Thu, 2 Jul 2026 16:02:03 +0200 Subject: [PATCH 04/11] =?UTF-8?q?fix(pyquest):=20guardia=20azioni=20prima?= =?UTF-8?q?=20del=20check=20won=20=E2=80=94=20niente=20hang=20post-vittori?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fase 1: a mondo congelato (dopo win) move/turn_* ritornavano prima di _action_guard(), quindi 'while True: move()' dopo la vittoria ciclava su no-op senza far avanzare nessun contatore, bloccando il main thread. Ora la guardia scatta sempre: le azioni post-win restano no-op ma costano un passo e il loop muore con StepLimitError (trace: win seguito da step_limit). Minore: makeCodeId con hash base36 a larghezza fissa (padStart 7) per rendere impossibile la collisione teorica della concatenazione hash+contatore. Verificato con test CPython (19/19, stub config/py_back_trace) e live nel browser: scenari a/b/c dello step 5, loop post-win che termina con messaggio didattico, indipendenza tra istanze, smoke PyRunner senza regressioni. typecheck/lint/build verdi. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V9dpQX4sKY6tJkTetJz3ho --- src/components/PyQuest/index.tsx | 5 ++++- static/bry-libs/pyquest.py | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/PyQuest/index.tsx b/src/components/PyQuest/index.tsx index 3fcadba..6f320e2 100644 --- a/src/components/PyQuest/index.tsx +++ b/src/components/PyQuest/index.tsx @@ -62,7 +62,10 @@ function makeCodeId(seed: string, n: number): string { hash = (hash * 33) ^ seed.charCodeAt(i); } // Deve soddisfare la guardia /^pyr_[a-z0-9]+$/ di bryBridge (niente separatori). - return `pyr_${(hash >>> 0).toString(36)}${n.toString(36)}`; + // 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 PyQuestInner(props: PyQuestProps) { diff --git a/static/bry-libs/pyquest.py b/static/bry-libs/pyquest.py index d4860ff..6b68516 100644 --- a/static/bry-libs/pyquest.py +++ b/static/bry-libs/pyquest.py @@ -174,9 +174,13 @@ def _check_win(): # --- API studente: azioni ----------------------------------------------------- def move(): + # La guardia scatta PRIMA del check `won`: a mondo congelato le azioni + # restano no-op ma costano comunque un passo, altrimenti un + # `while True: move()` dopo la vittoria ciclerebbe per sempre senza che + # nessun contatore avanzi (main thread bloccato). + _action_guard() if _world.won: return - _action_guard() hero = _world.hero dx, dy = DIRS[hero.facing] nx, ny = hero.x + dx, hero.y + dy @@ -189,18 +193,18 @@ def move(): def turn_left(): + _action_guard() if _world.won: return - _action_guard() _world.hero.facing = _TURN_LEFT[_world.hero.facing] _emit({'t': 'turn', 'f': _world.hero.facing}) _after_action() def turn_right(): + _action_guard() if _world.won: return - _action_guard() _world.hero.facing = _TURN_RIGHT[_world.hero.facing] _emit({'t': 'turn', 'f': _world.hero.facing}) _after_action() From 02ec74aea628db3f1f11b80ea64e55d080a3f8eb Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Fri, 3 Jul 2026 01:42:15 +0200 Subject: [PATCH 05/11] feat(pyquest): renderer+player, dati mondo e orchestrazione (fase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin Docusaurus `pyquest` che carica e valida a build-time i mondi in static/pyquest//world.json (schema versionato, guardia quoting sui campi iniettati nel motore Python). GameScene renderizza la griglia e i personaggi, GamePlayer anima la trace step-by-step, applyEvent.ts deriva lo stato del mondo da ogni evento. PyQuest/index.tsx orchestra editor, esecuzione (runLevel) e animazione con la macchina a stati idle → executing → animating → finished, esito won > failed > error. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V9dpQX4sKY6tJkTetJz3ho --- docs/pyquest-test.mdx | 70 ++-- docusaurus.config.ts | 1 + plugins/pyquest/index.js | 287 +++++++++++++++ src/components/PyQuest/GamePlayer.module.css | 133 +++++++ src/components/PyQuest/GamePlayer.tsx | 256 ++++++++++++++ src/components/PyQuest/GameScene.module.css | 349 ++++++++++++++++++ src/components/PyQuest/GameScene.tsx | 238 +++++++++++++ src/components/PyQuest/PyQuest.module.css | 87 +++++ src/components/PyQuest/applyEvent.ts | 115 ++++++ src/components/PyQuest/characters.tsx | 177 ++++++++++ src/components/PyQuest/index.tsx | 351 ++++++++++++------- src/components/PyQuest/runLevel.ts | 2 +- src/components/PyQuest/useWorldData.ts | 16 + static/pyquest/mondo-01/world.json | 51 +++ 14 files changed, 1980 insertions(+), 153 deletions(-) create mode 100644 plugins/pyquest/index.js create mode 100644 src/components/PyQuest/GamePlayer.module.css create mode 100644 src/components/PyQuest/GamePlayer.tsx create mode 100644 src/components/PyQuest/GameScene.module.css create mode 100644 src/components/PyQuest/GameScene.tsx create mode 100644 src/components/PyQuest/PyQuest.module.css create mode 100644 src/components/PyQuest/applyEvent.ts create mode 100644 src/components/PyQuest/characters.tsx create mode 100644 src/components/PyQuest/useWorldData.ts create mode 100644 static/pyquest/mondo-01/world.json diff --git a/docs/pyquest-test.mdx b/docs/pyquest-test.mdx index 7c4f46c..1a9fa9d 100644 --- a/docs/pyquest-test.mdx +++ b/docs/pyquest-test.mdx @@ -1,39 +1,61 @@ --- unlisted: true title: PyQuest — pagina di test -description: Harness di verifica del motore PyQuest (trace-based, mini-giochi a griglia) +description: Harness di verifica del motore + renderer PyQuest (mini-giochi a griglia) --- -# PyQuest — sandbox di test del motore +# PyQuest — sandbox di test -Pagina temporanea per validare il **motore** di PyQuest (fase 1). Non è linkata -dalla sidebar (`unlisted: true`): la raggiungi solo via URL `/docs/pyquest-test`. -Qui il componente è in versione **harness**: editor + bottone Esegui + dump grezzo -degli eventi emessi dal motore Python. Renderer e player arrivano nella fase 2. +Pagina temporanea per validare **motore + renderer + player** di PyQuest (fasi +1–2). Non è linkata dalla sidebar (`unlisted: true`): la raggiungi solo via URL +`/docs/pyquest-test`. Il componente è ora giocabile end-to-end: editor + Esegui + +board animata passo per passo. -Il livello di default è una griglia 5×5 con Byte in `(1, 1)` rivolto a est e un -muro a due celle di distanza. +## 1. Livello dal plugin dati (world/level) -## 1. Movimento base +Livello `muoviti` del mondo `mondo-01` (livelli provvisori di collaudo), +risolto dai global data del plugin `pyquest`. Il codice iniziale porta Byte +sulla bandiera: premi Esegui e guarda l'animazione. -Digita `for _ in range(3): move()` e premi Esegui: attesi `move`, `move`, `bump` -(la terza mossa sbatte contro il muro). + - +## 2. Navigazione con i sensori -## 2. Verifica indipendenza tra istanze +Livello `attorno-al-muro`: c'è un blocco centrale da aggirare, più una gemma e un +fantasma come scenografia (combat e raccolta arrivano nella fase 3). Prova a far +arrivare Byte in basso a destra con un ciclo `while not on_goal()`. -Due harness sullo stesso livello sulla stessa pagina: devono avere `codeId` -diversi e trace indipendenti (esegui l'uno, l'altro resta fermo). + - +## 3. Indipendenza tra istanze -## Note per la verifica (checklist step 5) +Due istanze dello stesso livello sulla stessa pagina: devono avere trace e player +indipendenti (esegui l'una, l'altra resta ferma). -- **a)** `for _ in range(3): move()` → `move, move, bump`. -- **b)** un errore a riga 2 (es. `move()` poi `boom`) → eventi fino a riga 1 - **più** un traceback su stderr che indica **riga 2**. -- **c)** due Esegui consecutivi producono trace identiche (nessuna - contaminazione tra run del modulo `pyquest` cacheato). -- Prova anche `while not on_goal(): move()` e la vittoria (`win`) quando Byte - raggiunge `(3, 3)` — serve girare e muoversi (`turn_right()` + `move()`). + + +## 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 step 10-12) + +- **a)** Esegui il livello 1: attesi `move, move, turn, move, move`, poi + autoplay da passo 0 e — a fine animazione — il pannello di vittoria con il + flavor text di Byte. +- **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)** Il livello 2 disegna gemma e fantasma fermi; Byte li attraversa + (niente combat/raccolta finché non arriva la fase 3). Sbattere più volte + contro il blocco centrale senza vincere → flavor «bump ripetuto». +- **g)** Dark mode: board, sprite e pannelli esito leggibili in entrambi i + temi; la griglia scala col contenitore (`--pq-cell`). diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 5985ed5..05404a5 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', [ diff --git a/plugins/pyquest/index.js b/plugins/pyquest/index.js new file mode 100644 index 0000000..8adbcf8 --- /dev/null +++ b/plugins/pyquest/index.js @@ -0,0 +1,287 @@ +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; +} + +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 }) { + actions.setGlobalData({ worlds: content.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..324f95d --- /dev/null +++ b/src/components/PyQuest/GamePlayer.module.css @@ -0,0 +1,133 @@ +/* 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); +} + +/* --- 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..87c2a38 --- /dev/null +++ b/src/components/PyQuest/GamePlayer.tsx @@ -0,0 +1,256 @@ +/** + * 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, + useRef, + 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). + const firstRender = useRef(true); + useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + return; + } + dispatch({ type: 'RESET' }); + dispatch({ type: 'TOGGLE_PLAY' }); + }, [playKey]); + + // 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..94a9278 --- /dev/null +++ b/src/components/PyQuest/GameScene.module.css @@ -0,0 +1,349 @@ +/* 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; + --pq-floor: #1e2230; + --pq-wall: #2b3245; + --pq-grid-line: rgba(255, 255, 255, 0.05); + --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..2e6dbab --- /dev/null +++ b/src/components/PyQuest/GameScene.tsx @@ -0,0 +1,238 @@ +/** + * 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). +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/PyQuest.module.css b/src/components/PyQuest/PyQuest.module.css new file mode 100644 index 0000000..99a3e62 --- /dev/null +++ b/src/components/PyQuest/PyQuest.module.css @@ -0,0 +1,87 @@ +/* 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; +} + +/* --- 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..25aaae2 --- /dev/null +++ b/src/components/PyQuest/characters.tsx @@ -0,0 +1,177 @@ +/** + * 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' }, +}; + +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' }, +}; + +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 index 6f320e2..45aea47 100644 --- a/src/components/PyQuest/index.tsx +++ b/src/components/PyQuest/index.tsx @@ -1,29 +1,47 @@ /** - * PyQuest — mini-giochi a griglia con Python (spec: PyQuest). + * PyQuest — mini-giochi a griglia con Python (spec step 12). * - * ⚠️ VERSIONE HARNESS (step 5). Questa è la prima versione "debug" del - * componente: editor riusato da PyRunner + bottone Esegui + dump grezzo degli - * eventi ricevuti dal motore. Niente renderer, niente player: quelli arrivano - * nella fase 2 (step 10-12), quando questo file verrà riscritto (step 12). - * Serve solo a validare il motore trace-based end-to-end. + * 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, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import BrowserOnly from '@docusaurus/BrowserOnly'; import { usePluginData } from '@docusaurus/useGlobalData'; +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 { ensureBrython, type BrythonConfig } from '@site/src/pyBoot'; -import { runLevel } from './runLevel'; +import pyStyles from '@site/src/theme/PyRunner/styles.module.css'; +import { runLevel, MAX_STEPS_DEFAULT } from './runLevel'; +import { useWorldData } from './useWorldData'; +import { characterDef } from './characters'; +import GamePlayer from './GamePlayer'; 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` (fase 2, step 7). */ + /** 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; - /** Titolo mostrato sopra l'editor. */ + /** Personaggio (override): normalmente ereditato dal mondo. */ + character?: string; + /** Titolo mostrato nella toolbar (override del titolo del livello). */ title?: string; } @@ -32,25 +50,9 @@ interface PyRunnerGlobalData { brython?: BrythonConfig; } -type RunStatus = 'idle' | 'running' | 'done' | 'error'; - -// Livello di fallback per l'harness: griglia 5×5, muro a 2 celle davanti a Byte -// così `for _ in range(3): move()` produce move, move, bump. -const DEFAULT_LEVEL: LevelDef = { - id: 'harness', - title: 'Harness 5×5', - grid: { - legend: { '#': 'wall', '.': 'floor' }, - rows: ['#####', '#...#', '#...#', '#...#', '#####'], - }, - hero: { x: 1, y: 1, facing: 'east', hp: 3 }, - goal: { x: 3, y: 3 }, - win: [{ type: 'reach' }], - starterCode: - '# Muovi Byte. Prova a cambiare il codice ed esegui.\nfor _ in range(3):\n move()\n', - hints: [], - par: 6, -}; +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. @@ -68,30 +70,51 @@ function makeCodeId(seed: string, n: number): string { return `pyr_${h}${n.toString(36)}`; } +function ErrorBox({ children }: { children: React.ReactNode }) { + return
{children}
; +} + function PyQuestInner(props: PyQuestProps) { - const data = usePluginData('pyrunner') as PyRunnerGlobalData | undefined; - const libUrl = data?.libUrl ?? ''; - const brython = data?.brython; + const pyrunner = usePluginData('pyrunner') as PyRunnerGlobalData | undefined; + const worlds = useWorldData(); + 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 ?? ''; - const level = props.levelData ?? DEFAULT_LEVEL; const seed = props.world && props.level ? `${props.world}/${props.level}` - : JSON.stringify(level); - + : JSON.stringify(level ?? props); const instanceN = useRef(-1); if (instanceN.current < 0) instanceN.current = idCounter++; - const codeId = useMemo(() => makeCodeId(seed, instanceN.current), [seed]); + 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 [status, setStatus] = useState('idle'); + const [playKey, setPlayKey] = useState(0); + const [hasEdits, setHasEdits] = useState(false); + const [currentCode, setCurrentCode] = useState(starterCode); const editorRef = useRef(null); const rootRef = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); + const eventsRef = useRef([]); + const logsRef = useRef([]); - // Precarica Brython quando l'harness entra nel viewport (pattern PyRunner). + // Precarica Brython quando il componente entra nel viewport (pattern PyRunner). useEffect(() => { const el = rootRef.current; const preload = () => { @@ -118,12 +141,37 @@ function PyQuestInner(props: PyQuestProps) { useEffect(() => () => cleanupRef.current?.(), []); + 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, + ); + setEvents([...trace]); + setLogs([...logsRef.current]); + setPhase('animating'); + setPlayKey((k) => k + 1); + }, []); + const handleRun = useCallback(() => { - const code = editorRef.current?.getCode() ?? level.starterCode; - setStatus('running'); + if (!level) return; + const code = editorRef.current?.getCode() ?? starterCode; + setPhase('executing'); + setOutcome(null); setEvents([]); setLogs([]); - const collected: GameEvent[] = []; + eventsRef.current = []; + logsRef.current = []; cleanupRef.current?.(); cleanupRef.current = runLevel({ code, @@ -132,116 +180,163 @@ function PyQuestInner(props: PyQuestProps) { libUrl, brython, onStart: () => { - collected.length = 0; - setEvents([]); - setLogs([]); + eventsRef.current = []; + logsRef.current = []; }, onEvent: (ev) => { - collected.push(ev); - setEvents([...collected]); + eventsRef.current.push(ev); }, onLog: (kind, text) => { - setLogs((prev) => [...prev, { kind, text, atStep: collected.length }]); - if (kind === 'stderr') setStatus('error'); + logsRef.current.push({ kind, text, atStep: eventsRef.current.length }); }, - onDone: () => setStatus((s) => (s === 'error' ? s : 'done')), + onDone: finishRun, onError: (err) => { - setStatus('error'); - setLogs((prev) => [ - ...prev, - { - kind: 'stderr', - text: `[PyQuest] ${err.message}\n`, - atStep: collected.length, - }, - ]); + logsRef.current.push({ + kind: 'stderr', + text: `[PyQuest] ${err.message}\n`, + atStep: eventsRef.current.length, + }); + finishRun(); }, }); - }, [level, codeId, libUrl, brython]); + }, [level, starterCode, codeId, libUrl, brython, finishRun]); + + 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)); + } + }, []); if (!libUrl) { return ( -
- PyQuest (harness): plugin pyrunner non registrato — libUrl - mancante. -
+ + 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 boxStyle: React.CSSProperties = { - border: '1px solid var(--at-border)', - borderRadius: 'var(--radius-m, 10px)', - padding: 8, - margin: '8px 0', - background: 'var(--at-bg-subtle)', - fontFamily: '"Monaspace Neon", ui-monospace, monospace', - fontSize: '0.85rem', - whiteSpace: 'pre-wrap', - maxHeight: 240, - overflow: 'auto', - }; + 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(''); return ( -
-
- {props.title ?? level.title}{' '} - - — harness ({status}) - -
-
- -
- -
- Eventi ({events.length}) -
-
- {events.map((ev, i) => `${i}: ${JSON.stringify(ev)}`).join('\n') || '—'} -
- {logs.length > 0 && ( - <> -
- Console -
-
- {logs.map((l) => `[${l.kind}@${l.atStep}] ${l.text}`).join('')} +
+
+
+ + + {/* Pannelli di esito con il flavor text del personaggio (D13). */} + {phase === 'finished' && outcome === 'won' && ( +
+ {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

+
{stderrText}
+
+ )} + {phase === 'finished' && outcome === null && bumpCount >= 2 && ( +
+ {char.name}: {char.flavor.bump} +
+ )} +
+ +
+ +
+
- - )} +
+
); } export default function PyQuest(props: PyQuestProps) { return ( - PyQuest…
}> + PyQuest…
}> {() => } ); diff --git a/src/components/PyQuest/runLevel.ts b/src/components/PyQuest/runLevel.ts index 1f128be..f0776d3 100644 --- a/src/components/PyQuest/runLevel.ts +++ b/src/components/PyQuest/runLevel.ts @@ -15,7 +15,7 @@ import type { BrythonConfig } from '@site/src/pyBoot'; import { runPython, type LogKind } from '@site/src/theme/PyRunner/bryBridge'; import type { GameEvent, LevelDef } from './types'; -const MAX_STEPS_DEFAULT = 500; +export const MAX_STEPS_DEFAULT = 500; /** Sottoinsieme del livello che il motore Python consuma davvero. */ function runtimeLevel(level: LevelDef): Record { 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/static/pyquest/mondo-01/world.json b/static/pyquest/mondo-01/world.json new file mode 100644 index 0000000..95003dd --- /dev/null +++ b/static/pyquest/mondo-01/world.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "id": "mondo-01", + "title": "Mondo di collaudo", + "description": "Livelli provvisori di collaudo (fase 2): saranno sostituiti dal mondo pilota definitivo allo step 18.", + "character": "byte", + "volume": "programmatore", + "levels": [ + { + "id": "muoviti", + "title": "Fai muovere Byte", + "grid": { + "legend": { "#": "wall", ".": "floor" }, + "rows": ["#####", "#...#", "#...#", "#...#", "#####"] + }, + "hero": { "x": 1, "y": 1, "facing": "east", "hp": 3 }, + "goal": { "x": 3, "y": 3 }, + "win": [{ "type": "reach" }], + "starterCode": "# Porta Byte sulla bandiera in (3, 3).\nmove()\nmove()\nturn_right()\nmove()\nmove()\n", + "solution": "move()\nmove()\nturn_right()\nmove()\nmove()\n", + "hints": [ + "Byte guarda a est: due move() lo portano al muro di destra.", + "Con turn_right() gira verso sud, poi altri due move() lo mettono sul goal." + ], + "par": 5 + }, + { + "id": "attorno-al-muro", + "title": "Aggira l'ostacolo", + "grid": { + "legend": { "#": "wall", ".": "floor" }, + "rows": ["#######", "#.....#", "#.###.#", "#.#.#.#", "#.###.#", "#.....#", "#######"] + }, + "hero": { "x": 1, "y": 1, "facing": "south", "hp": 3 }, + "goal": { "x": 5, "y": 5 }, + "win": [{ "type": "reach" }], + "starterCode": "# Aggira il blocco centrale e raggiungi la bandiera in basso a destra.\nwhile not on_goal():\n if is_blocked():\n turn_left()\n move()\n", + "hints": [ + "Usa i sensori: is_blocked() ti dice se hai un muro davanti.", + "Un ciclo while not on_goal() ripete finché non sei arrivato." + ], + "par": 12, + "enemies": [ + { "id": "e1", "kind": "ghost", "x": 1, "y": 5, "hp": 2, "behavior": "static" } + ], + "resources": [ + { "id": "g1", "kind": "gem", "x": 5, "y": 1 } + ] + } + ] +} From 29f345417e19694fa44b4413b982403d4683f53b Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Fri, 3 Jul 2026 02:13:33 +0200 Subject: [PATCH 06/11] feat(pyquest): combat, risorse, hint, progressione e stelle (fase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 13 — motore completo (static/bry-libs/pyquest.py): - classi Enemy/Resource; tick nemici deterministico (zero RNG): static attacca se adiacente, melee attacca o fa un passo greedy verso l'eroe (asse col delta maggiore, a parità x, poi l'altro asse, altrimenti fermo; niente sovrapposizioni tra nemici o con l'eroe); - attack()/collect(); sensori enemy_ahead/on_resource/health/inventory; move() e is_blocked() ora trattano il nemico come blocco (bump); - GameOverError (sollevata dopo l'evento death → la precedenza esito won>failed>error mostra la UI di sconfitta, non il pannello errore); - win collect (inventory>=qty) e defeat (tutti i target, o count; guard len>0 anti-vittoria-fantasma); - guardia _action_guard PRIMA del check won anche in attack/collect (emendamento D7). Step 15 — HintPanel.tsx (reveal progressivo, stato in memoria) + slot "Spiegamelo facile" cablato in index.tsx (buildExplainText + copyToClipboard riusati da PyRunner). Step 16 — useProgress.ts (chiave versionata pdb:pyquest:progress, parse difensivo con reset, sync same-tab via CustomEvent + storage cross-tab sul pattern di usePref; recordWin minimizza bestSteps, isUnlocked calcolato; countActions = move/turn/bump/attack/collect fino al win; starsFor D11) + pannello vittoria con stelle e "nuovo record" + striscia "Completato · record N" persistente + stili in PyQuest.module.css. Step 14 (scena combat) era già coperto dalla fase 2 (applyEvent + GameScene gestivano già gli eventi combat/collect, HUD PV/inventario e fx): nessuna modifica, solo verifica. Dati: livello di collaudo "duello" (bug melee + gemma, win defeat+collect, par 7) in mondo-01/world.json; tolto il fantasma decorativo dal livello 2. Harness docs/pyquest-test.mdx aggiornato (sezione combat + note h/i/j). Verifica: motore validato sotto CPython (trace del duello deterministica su 3 run, win a 7 azioni; morte -> death+GameOverError; while True su livello senza nemici -> step_limit; reach win). typecheck+lint+build verdi. Verifica visiva nel browser (Chrome): combat, raccolta, stelle, persistenza del record al reload, hint progressivi, dark mode. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V9dpQX4sKY6tJkTetJz3ho --- docs/pyquest-test.mdx | 35 +++- src/components/PyQuest/HintPanel.tsx | 48 ++++++ src/components/PyQuest/PyQuest.module.css | 108 +++++++++++++ src/components/PyQuest/index.tsx | 79 ++++++++- src/components/PyQuest/useProgress.ts | 169 +++++++++++++++++++ static/bry-libs/pyquest.py | 189 +++++++++++++++++++++- static/pyquest/mondo-01/world.json | 30 +++- 7 files changed, 637 insertions(+), 21 deletions(-) create mode 100644 src/components/PyQuest/HintPanel.tsx create mode 100644 src/components/PyQuest/useProgress.ts diff --git a/docs/pyquest-test.mdx b/docs/pyquest-test.mdx index 1a9fa9d..dc3cf3e 100644 --- a/docs/pyquest-test.mdx +++ b/docs/pyquest-test.mdx @@ -21,12 +21,22 @@ sulla bandiera: premi Esegui e guarda l'animazione. ## 2. Navigazione con i sensori -Livello `attorno-al-muro`: c'è un blocco centrale da aggirare, più una gemma e un -fantasma come scenografia (combat e raccolta arrivano nella fase 3). Prova a far -arrivare Byte in basso a destra con un ciclo `while not on_goal()`. +Livello `attorno-al-muro`: c'è un blocco centrale da aggirare (più una gemma come +scenografia). Prova a far arrivare Byte in basso a destra con un ciclo +`while not on_goal()`. +## 2-bis. Combat, raccolta, hint e stelle (fase 3) + +Livello `duello`: un bug **melee** ti viene incontro. Colpiscilo con `attack()` +(ha 2 PV), poi raccogli la gemma con `collect()`. La vittoria richiede *sia* +sconfiggere il bug *sia* raccogliere la gemma (`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 @@ -54,8 +64,17 @@ pannello d'errore esplicito. 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)** Il livello 2 disegna gemma e fantasma fermi; Byte li attraversa - (niente combat/raccolta finché non arriva la fase 3). Sbattere più volte - contro il blocco centrale senza vincere → flavor «bump ripetuto». -- **g)** Dark mode: board, sprite e pannelli esito leggibili in entrambi i - temi; la griglia scala col contenitore (`--pq-cell`). +- **f)** Sbattere più volte contro il blocco centrale del livello 2 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 `duello` (fase 3): il bug melee si avvicina a ogni azione; due + `attack()` lo sconfiggono; `collect()` raccoglie la gemma; con la soluzione 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. `while True: move()` → step limit nel tono di Byte. +- **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. 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 index 99a3e62..e11decc 100644 --- a/src/components/PyQuest/PyQuest.module.css +++ b/src/components/PyQuest/PyQuest.module.css @@ -77,6 +77,114 @@ overflow: auto; } +.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 { diff --git a/src/components/PyQuest/index.tsx b/src/components/PyQuest/index.tsx index 45aea47..47fc63c 100644 --- a/src/components/PyQuest/index.tsx +++ b/src/components/PyQuest/index.tsx @@ -19,16 +19,26 @@ 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'; @@ -74,9 +84,25 @@ function ErrorBox({ children }: { children: React.ReactNode }) { return
{children}
; } +/** 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; @@ -92,6 +118,11 @@ function PyQuestInner(props: PyQuestProps) { 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}` @@ -157,11 +188,25 @@ function PyQuestInner(props: PyQuestProps) { ? 'error' : null, ); + // Vittoria: salva completamento e record (solo per livelli identificabili). + if (hasWin && props.world && props.level) { + 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; @@ -271,10 +316,26 @@ function PyQuestInner(props: PyQuestProps) { .map((l) => l.text) .join(''); + // 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); + const isRecord = + outcome === 'won' && + (savedProgress === undefined || runSteps <= savedProgress.bestSteps); + return (
+ {/* Record persistito: visibile anche a freddo (verifica reload). */} + {savedProgress?.done && ( +
+ + Completato · record {savedProgress.bestSteps} azioni +
+ )} + - {char.name}: {char.flavor.win} +
+ + + {runSteps} azioni · par {level.par} + {isRecord && ( + · nuovo record! + )} + +
+

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

)} {phase === 'finished' && outcome === 'failed' && ( @@ -317,6 +389,8 @@ function PyQuestInner(props: PyQuestProps) { code={currentCode} onRun={handleRun} onReset={handleReset} + showExplain + onExplain={handleExplain} />
+
diff --git a/src/components/PyQuest/useProgress.ts b/src/components/PyQuest/useProgress.ts new file mode 100644 index 0000000..7ae6f33 --- /dev/null +++ b/src/components/PyQuest/useProgress.ts @@ -0,0 +1,169 @@ +/** + * 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`. */ + recordWin(worldId: string, levelId: string, steps: number): void; + /** 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) => { + // 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 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 + }, + [], + ); + + 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/static/bry-libs/pyquest.py b/static/bry-libs/pyquest.py index 6b68516..39682d7 100644 --- a/static/bry-libs/pyquest.py +++ b/static/bry-libs/pyquest.py @@ -29,8 +29,9 @@ __all__ = [ '_load_level', - 'move', 'turn_left', 'turn_right', - 'is_blocked', 'on_goal', + 'move', 'turn_left', 'turn_right', 'attack', 'collect', + 'is_blocked', 'enemy_ahead', 'on_goal', 'on_resource', + 'health', 'inventory', ] # --- Costanti ----------------------------------------------------------------- @@ -57,6 +58,20 @@ class StepLimitError(Exception): pass +class GameOverError(Exception): + """L'eroe è morto: aborta i cicli in corso su un eroe ormai fuori gioco. + + L'evento `death` è già in trace quando questa viene sollevata, quindi lato + React la sconfitta si mostra come UI dedicata (non come pannello d'errore): + la precedenza degli esiti è won > failed > error. + """ + pass + + +def _sign(d): + return (d > 0) - (d < 0) + + # --- Modello ------------------------------------------------------------------ class Hero: @@ -68,6 +83,26 @@ def __init__(self, x, y, facing, hp): self.inventory = {} +class Enemy: + def __init__(self, data): + self.id = data['id'] + self.kind = data['kind'] + self.x = data['x'] + self.y = data['y'] + self.hp = data['hp'] + self.behavior = data.get('behavior', 'static') + self.alive = True + + +class Resource: + def __init__(self, data): + self.id = data['id'] + self.kind = data['kind'] + self.x = data['x'] + self.y = data['y'] + self.collected = False + + class World: def __init__(self, data): grid = data['grid'] @@ -87,6 +122,9 @@ def __init__(self, data): g = data.get('goal') self.goal = (g['x'], g['y']) if g else None + self.enemies = [Enemy(e) for e in data.get('enemies', [])] + self.resources = [Resource(r) for r in data.get('resources', [])] + self.win_spec = data.get('win', []) self.max_steps = data.get('maxSteps', MAX_STEPS_DEFAULT) @@ -102,9 +140,70 @@ def is_walkable(self, x, y): return False return True + def enemy_at(self, x, y): + """Nemico VIVO nella cella (x, y), oppure None.""" + for e in self.enemies: + if e.alive and (e.x, e.y) == (x, y): + return e + return None + + def resource_at(self, x, y): + """Risorsa NON raccolta nella cella (x, y), oppure None.""" + for r in self.resources: + if not r.collected and (r.x, r.y) == (x, y): + return r + return None + + def _enemy_can_enter(self, x, y): + # Un nemico non entra in muri/bordi, sulla cella dell'eroe (là attacca) + # né sopra un altro nemico vivo (niente sovrapposizioni). + if not self.is_walkable(x, y): + return False + if (x, y) == (self.hero.x, self.hero.y): + return False + return self.enemy_at(x, y) is None + + def _hero_take_hit(self, by_id): + self.hero.hp -= 1 + _emit({'t': 'hero_hit', 'hp': self.hero.hp, 'by': by_id}) + if self.hero.hp <= 0: + self.dead = True + _emit({'t': 'death'}) + raise GameOverError( + 'Il tuo eroe è caduto. Ripensa la strategia: attacca prima di ' + 'finire circondato, oppure evita i nemici.' + ) + + def _enemy_step(self, e): + # Un passo «greedy» verso l'eroe: prima l'asse col delta maggiore (a + # parità l'asse x), poi l'altro asse; se entrambi sono bloccati, fermo. + dx = self.hero.x - e.x + dy = self.hero.y - e.y + if abs(dx) >= abs(dy): + candidates = ((_sign(dx), 0), (0, _sign(dy))) + else: + candidates = ((0, _sign(dy)), (_sign(dx), 0)) + for sx, sy in candidates: + if sx == 0 and sy == 0: + continue + nx, ny = e.x + sx, e.y + sy + if self._enemy_can_enter(nx, ny): + e.x, e.y = nx, ny + _emit({'t': 'enemy_move', 'id': e.id, 'x': nx, 'y': ny}) + return + def tick(self): - # I nemici si muovono qui (step 13). Nel nucleo movimento è un no-op. - pass + # Turno dei nemici dopo ogni azione: ordine di lista, deterministico, + # zero RNG. `static` attacca solo se adiacente; `melee` attacca se + # adiacente, altrimenti fa un passo verso l'eroe. + for e in self.enemies: + if not e.alive: + continue + adjacent = abs(e.x - self.hero.x) + abs(e.y - self.hero.y) == 1 + if adjacent: + self._hero_take_hit(e.id) + elif e.behavior == 'melee': + self._enemy_step(e) _world = None # riassegnato a ogni _load_level → nessuna contaminazione tra run @@ -154,8 +253,20 @@ def _cond_met(cond): t = cond.get('type') if t == 'reach': return _world.goal is not None and (_world.hero.x, _world.hero.y) == _world.goal - # collect / defeat: implementati allo step 13. Finché non lo sono, la - # condizione è considerata NON soddisfatta (mai vittoria fantasma). + if t == 'collect': + kind = cond.get('kind') + qty = cond.get('qty', 1) + return _world.hero.inventory.get(kind, 0) >= qty + if t == 'defeat': + kind = cond.get('kind') + count = cond.get('count') + targets = [e for e in _world.enemies if kind is None or e.kind == kind] + defeated = sum(1 for e in targets if not e.alive) + if count is not None: + return defeated >= count + # «tutti i nemici (di quel kind)»: il guard len>0 evita la vittoria + # fantasma se, per errore d'autore, non ci sono nemici da sconfiggere. + return len(targets) > 0 and defeated == len(targets) return False @@ -184,7 +295,8 @@ def move(): hero = _world.hero dx, dy = DIRS[hero.facing] nx, ny = hero.x + dx, hero.y + dy - if _world.is_walkable(nx, ny): + # Muro/bordo o nemico davanti: no-op + bump (mai eccezione, spec D7). + if _world.is_walkable(nx, ny) and _world.enemy_at(nx, ny) is None: hero.x, hero.y = nx, ny _emit({'t': 'move', 'x': nx, 'y': ny, 'f': hero.facing}) else: @@ -210,15 +322,76 @@ def turn_right(): _after_action() +def attack(): + _action_guard() + if _world.won: + return + hero = _world.hero + dx, dy = DIRS[hero.facing] + tx, ty = hero.x + dx, hero.y + dy + target = _world.enemy_at(tx, ty) + if target is not None: + target.hp -= 1 + _emit({'t': 'attack', 'x': tx, 'y': ty, 'hit': True}) + _emit({'t': 'enemy_hit', 'id': target.id, 'hp': target.hp}) + if target.hp <= 0: + target.alive = False + _emit({'t': 'defeat', 'id': target.id}) + else: + _emit({'t': 'attack', 'x': tx, 'y': ty, 'hit': False}) + _after_action() + + +def collect(): + _action_guard() + if _world.won: + return + hero = _world.hero + res = _world.resource_at(hero.x, hero.y) + if res is not None: + res.collected = True + hero.inventory[res.kind] = hero.inventory.get(res.kind, 0) + 1 + _emit({'t': 'collect', 'id': res.id, 'kind': res.kind}) + else: + _emit({'t': 'bump', 'x': hero.x, 'y': hero.y}) + _after_action() + + # --- API studente: sensori (gratuiti, senza tick) ----------------------------- def is_blocked(): _sensor_guard() hero = _world.hero dx, dy = DIRS[hero.facing] - return not _world.is_walkable(hero.x + dx, hero.y + dy) + nx, ny = hero.x + dx, hero.y + dy + return not _world.is_walkable(nx, ny) or _world.enemy_at(nx, ny) is not None + + +def enemy_ahead(): + _sensor_guard() + hero = _world.hero + dx, dy = DIRS[hero.facing] + return _world.enemy_at(hero.x + dx, hero.y + dy) is not None def on_goal(): _sensor_guard() return _world.goal is not None and (_world.hero.x, _world.hero.y) == _world.goal + + +def on_resource(): + _sensor_guard() + return _world.resource_at(_world.hero.x, _world.hero.y) is not None + + +def health(): + _sensor_guard() + return _world.hero.hp + + +def inventory(kind=None): + _sensor_guard() + inv = _world.hero.inventory + if kind is None: + return sum(inv.values()) + return inv.get(kind, 0) diff --git a/static/pyquest/mondo-01/world.json b/static/pyquest/mondo-01/world.json index 95003dd..b5e8e32 100644 --- a/static/pyquest/mondo-01/world.json +++ b/static/pyquest/mondo-01/world.json @@ -40,12 +40,36 @@ "Un ciclo while not on_goal() ripete finché non sei arrivato." ], "par": 12, - "enemies": [ - { "id": "e1", "kind": "ghost", "x": 1, "y": 5, "hp": 2, "behavior": "static" } - ], "resources": [ { "id": "g1", "kind": "gem", "x": 5, "y": 1 } ] + }, + { + "id": "duello", + "title": "Duello con un bug", + "grid": { + "legend": { "#": "wall", ".": "floor" }, + "rows": ["########", "#......#", "#......#", "########"] + }, + "hero": { "x": 1, "y": 1, "facing": "east", "hp": 5 }, + "enemies": [ + { "id": "b1", "kind": "bug", "x": 6, "y": 1, "hp": 2, "behavior": "melee" } + ], + "resources": [ + { "id": "g1", "kind": "gem", "x": 3, "y": 2 } + ], + "win": [ + { "type": "defeat" }, + { "type": "collect", "kind": "gem", "qty": 1 } + ], + "starterCode": "# Il bug ti viene incontro: colpiscilo con attack(), poi raccogli la gemma.\nmove()\nmove()\nattack()\nattack()\nturn_right()\nmove()\ncollect()\n", + "solution": "move()\nmove()\nattack()\nattack()\nturn_right()\nmove()\ncollect()\n", + "hints": [ + "Il bug è di tipo melee: ti si avvicina di un passo a ogni tua azione. Non serve rincorrerlo.", + "attack() colpisce la cella davanti a te: falla mentre il bug è lì davanti. Servono due colpi (ha 2 PV).", + "Sconfitto il bug, girati verso la gemma con turn_right(), avvicinati e usa collect()." + ], + "par": 7 } ] } From 902cee78f6fdfc79fecebfa779d8ecd920fed3b4 Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Fri, 3 Jul 2026 10:23:49 +0200 Subject: [PATCH 07/11] fix(pyquest): record onesto, niente spoiler d'esito, reset player in render (review fase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review del codice della fase 3. Motore validato sotto CPython (19 check verdi: determinismo del duello, morte, step limit post-win, guard sensori, defeat count/kind, nessuna vittoria fantasma né sovrapposizioni melee): nessun bug lato Python. Correzioni tutte lato UI: - «nuovo record!» compariva a ogni pareggio: recordWin scrive il best PRIMA del render del pannello vittoria, quindi il confronto col best salvato era sempre vero eguagliando il proprio record. Ora recordWin decide (e restituisce) se il run è un record — prima vittoria o miglioramento stretto — e index.tsx lo tiene in uno stato catturato al momento della vittoria. - La striscia «Completato · record N» compariva all'inizio dell'animazione della prima vittoria (recordWin scatta in finishRun), rivelando l'esito in anticipo: durante l'animazione ora è visibile solo se il livello era già completato all'avvio del run. - GamePlayer: al re-run il pannello di esito restava visibile per tutto il replay — l'effect su playKey resettava il cursore DOPO che l'effect di onStepChange aveva notificato il cursore stantio del run precedente (già a fine trace) con la trace nuova, e il genitore chiudeva subito l'animazione. Reset spostato in render (pattern «adjust state during render»). - Plurali al valore 1: «1 stella su 3» (aria-label) e «1 azione». - HintPanel con key={level.id}: i suggerimenti rivelati ripartono da zero al cambio livello (anteprima live dell'editor, fase 4). Verifica: typecheck+lint+build verdi. Verifica visiva nel browser sul livello duello: prima vittoria con «nuovo record!» e striscia solo a fine animazione; pareggio senza «nuovo record!» né pannello anticipato; striscia e record persistenti al reload; console senza warning. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V9dpQX4sKY6tJkTetJz3ho --- src/components/PyQuest/GamePlayer.tsx | 19 ++++++------ src/components/PyQuest/index.tsx | 44 +++++++++++++++++++++------ src/components/PyQuest/useProgress.ts | 13 ++++++-- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/components/PyQuest/GamePlayer.tsx b/src/components/PyQuest/GamePlayer.tsx index 87c2a38..416fd63 100644 --- a/src/components/PyQuest/GamePlayer.tsx +++ b/src/components/PyQuest/GamePlayer.tsx @@ -17,7 +17,7 @@ import { useEffect, useMemo, useReducer, - useRef, + useState, type CSSProperties, } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -111,16 +111,17 @@ export default function GamePlayer({ { stepIndex: 0, playing: false, speedIdx: DEFAULT_SPEED_IDX }, ); - // Nuovo run: autoplay dal passo 0 (velocità conservata). - const firstRender = useRef(true); - useEffect(() => { - if (firstRender.current) { - firstRender.current = false; - return; - } + // 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' }); - }, [playKey]); + } // Autoplay. useEffect(() => { diff --git a/src/components/PyQuest/index.tsx b/src/components/PyQuest/index.tsx index 47fc63c..ae26bc5 100644 --- a/src/components/PyQuest/index.tsx +++ b/src/components/PyQuest/index.tsx @@ -84,10 +84,18 @@ 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) => ( (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(() => { @@ -190,7 +205,7 @@ function PyQuestInner(props: PyQuestProps) { ); // Vittoria: salva completamento e record (solo per livelli identificabili). if (hasWin && props.world && props.level) { - recordWin(props.world, props.level, countActions(trace)); + setIsRecord(recordWin(props.world, props.level, countActions(trace))); } setEvents([...trace]); setLogs([...logsRef.current]); @@ -211,8 +226,10 @@ function PyQuestInner(props: PyQuestProps) { 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 = []; @@ -244,7 +261,7 @@ function PyQuestInner(props: PyQuestProps) { finishRun(); }, }); - }, [level, starterCode, codeId, libUrl, brython, finishRun]); + }, [level, starterCode, codeId, libUrl, brython, finishRun, savedProgress]); const handleReset = useCallback(() => { editorRef.current?.setCode(starterCode); @@ -320,19 +337,24 @@ function PyQuestInner(props: PyQuestProps) { // `savedProgress` porta invece il record persistito, visibile anche a freddo. const runSteps = countActions(events); const runStars = starsFor(runSteps, level.par); - const isRecord = - outcome === 'won' && - (savedProgress === undefined || runSteps <= savedProgress.bestSteps); + // 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). */} - {savedProgress?.done && ( + {showCompleted && savedProgress && (
- Completato · record {savedProgress.bestSteps} azioni + + Completato · record {savedProgress.bestSteps}{' '} + {azioni(savedProgress.bestSteps)} +
)} @@ -351,7 +373,7 @@ function PyQuestInner(props: PyQuestProps) {
- {runSteps} azioni · par {level.par} + {runSteps} {azioni(runSteps)} · par {level.par} {isRecord && ( · nuovo record! )} @@ -402,7 +424,9 @@ function PyQuestInner(props: PyQuestProps) { ariaLabel="Editor di codice Python del livello" />
- + {/* key: al cambio livello (anteprima live dell'editor) i suggerimenti + rivelati ripartono da zero. */} +
diff --git a/src/components/PyQuest/useProgress.ts b/src/components/PyQuest/useProgress.ts index 7ae6f33..d1afcc3 100644 --- a/src/components/PyQuest/useProgress.ts +++ b/src/components/PyQuest/useProgress.ts @@ -62,8 +62,13 @@ function writeProgress(next: ProgressShape) { 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`. */ - recordWin(worldId: string, levelId: string, steps: number): void; + /** + * 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; } @@ -100,12 +105,13 @@ export function useProgress(): UseProgress { ); const recordWin = useCallback( - (worldId: string, levelId: string, steps: number) => { + (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, @@ -122,6 +128,7 @@ export function useProgress(): UseProgress { }; writeProgress(next); setProgress(next); // aggiorna subito questo blocco; l'evento fa gli altri + return isRecord; }, [], ); From 3b50c4af238b4a8ab7aa9e8ed9b73e552879d26a Mon Sep 17 00:00:00 2001 From: Marco Farina Date: Fri, 3 Jul 2026 22:18:17 +0200 Subject: [PATCH 08/11] =?UTF-8?q?feat(pyquest):=20editor=20livelli=20?= =?UTF-8?q?=E2=80=94=20paint,=20resize,=20form,=20anteprima=20live,=20expo?= =?UTF-8?q?rt/import=20(fase=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuova pagina interna /level-editor (noindex, non in navbar): disegna un livello con palette+paint click&drag, ridimensiona la griglia, compila i metadati (vittoria, nemici, risorse, hint, codice iniziale), lo vede girare nell'anteprima live e ne esporta/importa il JSON da incollare in static/pyquest//world.json. Validazione client che ricalca le regole del plugin build-time. Esporta FACING_DEG da GameScene e ENEMY_KINDS/RESOURCE_KINDS da characters perché l'editor li riusa nei menu; index.tsx ora resetta lo stato di gioco quando cambia l'identità del livello o dello starterCode a runtime, per l'anteprima live dell'editor. Verificato nel browser (build pulita, paint/resize/validazione, esecuzione codice nell'anteprima con combat, round-trip export→import esatto, nessun errore console). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V9dpQX4sKY6tJkTetJz3ho --- src/components/PyQuest/GameScene.tsx | 3 +- src/components/PyQuest/characters.tsx | 6 + src/components/PyQuest/index.tsx | 28 + src/pages/level-editor.module.css | 364 +++++++ src/pages/level-editor.tsx | 1287 +++++++++++++++++++++++++ 5 files changed, 1687 insertions(+), 1 deletion(-) create mode 100644 src/pages/level-editor.module.css create mode 100644 src/pages/level-editor.tsx diff --git a/src/components/PyQuest/GameScene.tsx b/src/components/PyQuest/GameScene.tsx index 2e6dbab..d980a84 100644 --- a/src/components/PyQuest/GameScene.tsx +++ b/src/components/PyQuest/GameScene.tsx @@ -33,7 +33,8 @@ import styles from './GameScene.module.css'; const MAX_CELL_PX = 48; // Gradi di rotazione dello sprite per facing (disegnato rivolto a nord). -const FACING_DEG: Record = { +// Esportato: anche l'editor livelli orienta l'eroe con la stessa tabella. +export const FACING_DEG: Record = { north: 0, east: 90, south: 180, diff --git a/src/components/PyQuest/characters.tsx b/src/components/PyQuest/characters.tsx index 25aaae2..caf742b 100644 --- a/src/components/PyQuest/characters.tsx +++ b/src/components/PyQuest/characters.tsx @@ -144,6 +144,9 @@ const ENEMIES: Record = { 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)', @@ -161,6 +164,9 @@ const RESOURCES: Record = { 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)', diff --git a/src/components/PyQuest/index.tsx b/src/components/PyQuest/index.tsx index ae26bc5..e9979b8 100644 --- a/src/components/PyQuest/index.tsx +++ b/src/components/PyQuest/index.tsx @@ -187,6 +187,34 @@ function PyQuestInner(props: PyQuestProps) { 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'); 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 + +