From 8d8a811ede254a2b73c3b9e94755c52e16997b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es?= Date: Thu, 26 Mar 2026 03:50:07 +0100 Subject: [PATCH] feat: circuit builder, electronics lab, SPICE simulator, expanded skill tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workbenches: - Circuit Builder: drag-and-drop logic gates, wire connections, truth table verification, fullscreen mode - Electronics Lab: SPICE-like DC simulator with MNA solver, voltage sources, resistors, capacitors, LEDs, switches, NMOS/PMOS transistors, voltmeter, ammeter, play/pause simulation, fullscreen mode - Explanation renderer: auto-detects ASCII truth tables and renders them as styled HTML Skill tree: - 65+ nodes across 19 groups spanning math → electronics → CPU → ASM → OS → networking → web - Groups: Aritmética, Álgebra, Lógica, Electrónica, Circuitos Digitales, Secuenciales, Tu CPU, Verilog/HDL, Arquitectura Extendida, Sistemas Operativos, Programación en C, Redes, La Web, Señales, Síntesis Audio, Gráficos, Tu Consola - Dependency highlighting: clicking a node dims all others and highlights the full path - Group boxes with colored borders around related nodes - Dependency chain audit: fixed illogical prerequisites throughout the tree Content: - 24 electronics challenges (basics, series/parallel, capacitors, diodes, transistors, op-amps, power supplies) - 12 circuit builder challenges (logic gates, NAND universality, combinational circuits) - Fixed all explanation spoilers: examples now use different numbers than the challenge questions - Probe system now requires voltmeter/ammeter instruments instead of checking arbitrary node IDs UX: - Custom dark-themed scrollbars - Fullscreen mode for circuit/electronics editors (portal-based, Esc to exit) - SVG coordinate fix using getScreenCTM for accurate wire placement in fullscreen - Meter reading labels positioned correctly regardless of component rotation - Scratchpad defaults to closed, persists open/close state in localStorage - Empty placeholder nodes show "Próximamente" instead of appearing completed Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/globals.css | 34 + src/components/skill-tree/SkillGroupNode.tsx | 37 + src/components/skill-tree/SkillNode.tsx | 12 +- src/components/skill-tree/SkillNodeDetail.tsx | 253 ++++-- src/components/skill-tree/SkillTreeCanvas.tsx | 307 ++++++- .../workbench/ExplanationRenderer.tsx | 102 +++ src/components/workbench/WorkbenchShell.tsx | 30 +- .../circuit-builder/CircuitBuilder.tsx | 377 ++++++++ .../modules/circuit-builder/GateComponent.tsx | 138 +++ .../circuit-builder/simulateCircuit.ts | 85 ++ .../modules/electronics/ComponentSVG.tsx | 276 ++++++ .../modules/electronics/ElectronicsLab.tsx | 453 ++++++++++ .../electronics/simulateElectronics.ts | 361 ++++++++ src/data/challenges/circuits.ts | 257 ++++++ src/data/challenges/electronics.ts | 479 +++++++++++ src/data/challenges/math.ts | 35 +- src/data/skill-tree.ts | 812 +++++++++++++++++- src/lib/challenge-engine/verifier.ts | 112 +++ src/stores/useProgressStore.ts | 4 +- src/types/challenge.ts | 6 +- src/types/circuit.ts | 51 ++ src/types/electronics.ts | 91 ++ src/types/skill-tree.ts | 14 +- todo.md | 37 + 24 files changed, 4263 insertions(+), 100 deletions(-) create mode 100644 src/components/skill-tree/SkillGroupNode.tsx create mode 100644 src/components/workbench/ExplanationRenderer.tsx create mode 100644 src/components/workbench/modules/circuit-builder/CircuitBuilder.tsx create mode 100644 src/components/workbench/modules/circuit-builder/GateComponent.tsx create mode 100644 src/components/workbench/modules/circuit-builder/simulateCircuit.ts create mode 100644 src/components/workbench/modules/electronics/ComponentSVG.tsx create mode 100644 src/components/workbench/modules/electronics/ElectronicsLab.tsx create mode 100644 src/components/workbench/modules/electronics/simulateElectronics.ts create mode 100644 src/data/challenges/circuits.ts create mode 100644 src/data/challenges/electronics.ts create mode 100644 src/types/circuit.ts create mode 100644 src/types/electronics.ts create mode 100644 todo.md diff --git a/src/app/globals.css b/src/app/globals.css index e0f3510..e7091a3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -129,6 +129,30 @@ } } +/* Custom scrollbars */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); +} + /* React Flow dark mode overrides */ .react-flow__controls { background: var(--card) !important; @@ -176,6 +200,16 @@ animation: shake 0.5s ease-in-out; } +/* Fullscreen fade in */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.animate-fade-in { + animation: fadeIn 0.2s ease-out; +} + /* Ensure handles are visible and edges connect properly */ .react-flow__handle { opacity: 0; diff --git a/src/components/skill-tree/SkillGroupNode.tsx b/src/components/skill-tree/SkillGroupNode.tsx new file mode 100644 index 0000000..0e69e83 --- /dev/null +++ b/src/components/skill-tree/SkillGroupNode.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { memo } from 'react'; +import { type Node, type NodeProps } from '@xyflow/react'; + +type SkillGroupData = { + label: string; + icon: string; + color: string; + width: number; + height: number; +} & Record; + +type SkillGroupType = Node; + +export const SkillGroupNode = memo(function SkillGroupNode({ + data, +}: NodeProps) { + return ( +
+
+ {data.icon} + {data.label} +
+
+ ); +}); diff --git a/src/components/skill-tree/SkillNode.tsx b/src/components/skill-tree/SkillNode.tsx index 9b18856..29dd29c 100644 --- a/src/components/skill-tree/SkillNode.tsx +++ b/src/components/skill-tree/SkillNode.tsx @@ -5,7 +5,11 @@ import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Lock } from 'lucide-react'; import type { SkillNode, NodeStatus } from '@/types/skill-tree'; -type SkillNodeData = SkillNode & { status: NodeStatus } & Record; +type SkillNodeData = SkillNode & { + status: NodeStatus; + dimmed?: boolean; + highlighted?: boolean; +} & Record; type SkillNodeType = Node; const statusStyles: Record = { @@ -29,10 +33,14 @@ export const SkillNodeComponent = memo(function SkillNodeComponent({ }: NodeProps) { const status = data.status; const badge = statusBadge[status]; + const dimmed = data.dimmed; + const highlighted = data.highlighted; return (
diff --git a/src/components/skill-tree/SkillNodeDetail.tsx b/src/components/skill-tree/SkillNodeDetail.tsx index 6a92fd6..74c58c7 100644 --- a/src/components/skill-tree/SkillNodeDetail.tsx +++ b/src/components/skill-tree/SkillNodeDetail.tsx @@ -1,31 +1,61 @@ 'use client'; +import { useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import { X, Star, CheckCircle2, Lock, ArrowRight } from 'lucide-react'; +import { X, Star, CheckCircle2, Lock, ArrowRight, ArrowDown, GitBranch } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { getNodeById } from '@/data/skill-tree'; +import { skillNodes, getNodeById } from '@/data/skill-tree'; import { getChallengesForNode } from '@/data/challenges/math'; import { useProgressStore } from '@/stores/useProgressStore'; +import type { SkillNode } from '@/types/skill-tree'; interface SkillNodeDetailProps { nodeId: string; onClose: () => void; } +type Tab = 'challenges' | 'dependencies'; + +/** + * Walk the prerequisite graph backwards to build the full dependency chain. + * Returns nodes in topological order (root first → target last). + */ +function getDependencyChain(targetId: string): SkillNode[] { + const visited = new Set(); + const chain: SkillNode[] = []; + + function walk(id: string) { + if (visited.has(id)) return; + visited.add(id); + const node = getNodeById(id); + if (!node) return; + for (const prereq of node.prerequisites) { + walk(prereq); + } + chain.push(node); + } + + walk(targetId); + // Remove the target itself — we only want the path TO it + return chain.filter((n) => n.id !== targetId); +} + export function SkillNodeDetail({ nodeId, onClose }: SkillNodeDetailProps) { const router = useRouter(); const node = getNodeById(nodeId); const getNodeStatus = useProgressStore((s) => s.getNodeStatus); const completedChallenges = useProgressStore((s) => s.completedChallenges); + const [tab, setTab] = useState('challenges'); if (!node) return null; const status = getNodeStatus(nodeId); const challenges = getChallengesForNode(nodeId); const completedCount = challenges.filter((c) => completedChallenges[c.id]).length; + const depChain = useMemo(() => getDependencyChain(nodeId), [nodeId]); return (
@@ -74,61 +104,184 @@ export function SkillNodeDetail({ nodeId, onClose }: SkillNodeDetailProps) {
- {/* Challenges list */} - -
- {status === 'locked' ? ( -
- -

Nodo bloqueado

-

- Completa los prerrequisitos para desbloquear -

-
- ) : ( - challenges.map((challenge, index) => { - const isCompleted = !!completedChallenges[challenge.id]; - return ( - { - router.push(`/workbench/${encodeURIComponent(challenge.id)}`); - }} - > -
-
+ {/* Tabs */} +
+ + +
+ + {/* Tab content */} + + {tab === 'challenges' && ( +
+ {status === 'locked' ? ( +
+ +

Nodo bloqueado

+

+ Completa los prerrequisitos para desbloquear +

+
+ ) : challenges.length === 0 ? ( +
+

Próximamente

+

+ Los retos de esta habilidad aún no están disponibles +

+
+ ) : ( + challenges.map((challenge, index) => { + const isCompleted = !!completedChallenges[challenge.id]; + return ( + { + router.push(`/workbench/${encodeURIComponent(challenge.id)}`); + }} + > +
+
+
+ {isCompleted ? ( + + ) : ( + index + 1 + )} +
+
+

{challenge.title}

+

+ +{challenge.xpReward} XP +

+
+
+ {!isCompleted && ( + + )} +
+
+ ); + }) + )} +
+ )} + + {tab === 'dependencies' && ( +
+ {depChain.length === 0 ? ( +
+

Sin dependencias

+

+ Esta es una habilidad raíz +

+
+ ) : ( +
+ {depChain.map((dep, index) => { + const depStatus = getNodeStatus(dep.id); + const isCompleted = depStatus === 'completed'; + const isAvailable = depStatus === 'available' || depStatus === 'in-progress'; + + return ( +
- {isCompleted ? ( - - ) : ( - index + 1 + {/* Step number / status */} +
+ {isCompleted ? ( + + ) : ( + index + 1 + )} +
+ + {/* Node info */} +
+
+ {dep.icon} +

{dep.title}

+
+

{dep.discipline}

+
+ + {/* Status badge */} + {isCompleted && ( + Hecho + )} + {isAvailable && ( + Disponible + )} + {!isCompleted && !isAvailable && ( + )}
-
-

{challenge.title}

-

- +{challenge.xpReward} XP -

-
+ + {/* Connector arrow */} + {index < depChain.length - 1 && ( +
+ +
+ )}
- {!isCompleted && ( - - )} + ); + })} + + {/* Final arrow to current node */} +
+ +
+
+
+ {node.icon}
- - ); - }) - )} -
+
+

{node.title}

+

← Estás aquí

+
+
+
+ )} +
+ )}
); diff --git a/src/components/skill-tree/SkillTreeCanvas.tsx b/src/components/skill-tree/SkillTreeCanvas.tsx index 6a87025..08741d9 100644 --- a/src/components/skill-tree/SkillTreeCanvas.tsx +++ b/src/components/skill-tree/SkillTreeCanvas.tsx @@ -12,26 +12,213 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import Dagre from '@dagrejs/dagre'; -import { skillNodes, skillEdges } from '@/data/skill-tree'; +import { skillNodes, skillEdges, getNodeById } from '@/data/skill-tree'; import { useProgressStore } from '@/stores/useProgressStore'; import { SkillNodeComponent } from './SkillNode'; +import { SkillGroupNode } from './SkillGroupNode'; import { SkillNodeDetail } from './SkillNodeDetail'; +/** Get all ancestor node IDs (full dependency chain) for a given node */ +function getDependencyIds(targetId: string): Set { + const visited = new Set(); + function walk(id: string) { + if (visited.has(id)) return; + visited.add(id); + const node = getNodeById(id); + if (!node) return; + for (const prereq of node.prerequisites) { + walk(prereq); + } + } + walk(targetId); + visited.delete(targetId); // don't include the target itself + return visited; +} + const nodeTypes = { skillNode: SkillNodeComponent, + skillGroup: SkillGroupNode, }; const NODE_WIDTH = 170; const NODE_HEIGHT = 70; +const GROUP_PADDING = 40; + +// Define visual groups: which node IDs belong to each group +const SKILL_GROUPS: { + id: string; + label: string; + icon: string; + color: string; + nodeIds: string[]; +}[] = [ + { + id: 'group-arithmetic', + label: 'Aritmética', + icon: '🔢', + color: '#3b82f6', + nodeIds: [ + 'arithmetic.addition', 'arithmetic.subtraction', 'arithmetic.multiplication', + 'arithmetic.division', 'arithmetic.fractions', 'arithmetic.decimals', + 'arithmetic.percentages', 'number-theory.primes', 'number-theory.gcd-lcm', + ], + }, + { + id: 'group-algebra', + label: 'Álgebra', + icon: '📐', + color: '#a855f7', + nodeIds: [ + 'algebra.variables', 'algebra.equations', 'algebra.linear-systems', 'algebra.quadratics', + ], + }, + { + id: 'group-logic', + label: 'Lógica y Computación', + icon: '🧠', + color: '#f59e0b', + nodeIds: [ + 'logic.boolean', 'logic.binary', + ], + }, + { + id: 'group-electronics', + label: 'Electrónica', + icon: '🔋', + color: '#ef4444', + nodeIds: [ + 'electronics.basics', 'electronics.series-parallel', 'electronics.capacitors', + 'electronics.diodes', 'electronics.transistors', 'electronics.opamp', 'electronics.power', + ], + }, + { + id: 'group-circuits', + label: 'Circuitos Digitales', + icon: '⚡', + color: '#22c55e', + nodeIds: [ + 'electronics.logic-gates', 'electronics.combinational', + ], + }, + { + id: 'group-sequential', + label: 'Circuitos Secuenciales', + icon: '🔄', + color: '#06b6d4', + nodeIds: [ + 'sequential.flipflops', 'sequential.registers', 'sequential.counters', + ], + }, + { + id: 'group-cpu', + label: 'Tu CPU', + icon: '🖥️', + color: '#8b5cf6', + nodeIds: [ + 'cpu.alu', 'cpu.memory', 'cpu.control-isa', 'cpu.complete', + 'asm.stack-subroutines', 'asm.interrupts', + ], + }, + { + id: 'group-signals', + label: 'Procesamiento de Señales', + icon: '🌊', + color: '#14b8a6', + nodeIds: [ + 'signals.trigonometry', 'signals.waves', 'signals.sampling', + ], + }, + { + id: 'group-audio', + label: 'Síntesis de Audio', + icon: '🎹', + color: '#f97316', + nodeIds: [ + 'audio.oscillators', 'audio.filters', 'audio.synthesizer', 'audio.sound-card', + ], + }, + { + id: 'group-graphics', + label: 'Gráficos', + icon: '👾', + color: '#84cc16', + nodeIds: [ + 'graphics.pixels', 'graphics.framebuffer', 'graphics.sprites', 'graphics.tilemaps', 'graphics.gpu', + ], + }, + { + id: 'group-game', + label: 'Tu Consola', + icon: '🎮', + color: '#eab308', + nodeIds: [ + 'game.input', 'game.gameloop', 'game.collision', 'game.sound-integration', 'game.final', + ], + }, + { + id: 'group-hdl', + label: 'Verilog / HDL', + icon: '📝', + color: '#f472b6', + nodeIds: [ + 'hdl.basics', 'hdl.testbench', 'hdl.cpu-verilog', + ], + }, + { + id: 'group-ext-arch', + label: 'Arquitectura Extendida', + icon: '🏗️', + color: '#7c3aed', + nodeIds: [ + 'ext-arch.16bit', 'ext-arch.32bit', 'ext-arch.mmu', 'ext-arch.peripherals', + ], + }, + { + id: 'group-os', + label: 'Sistemas Operativos', + icon: '🧬', + color: '#0ea5e9', + nodeIds: [ + 'os.bootloader', 'os.kernel', 'os.processes', 'os.filesystem', 'os.drivers', + ], + }, + { + id: 'group-hlp', + label: 'Programación en C', + icon: '©️', + color: '#d946ef', + nodeIds: [ + 'hlp.c-basics', 'hlp.pointers-memory', 'hlp.data-structures', 'hlp.algorithms', 'hlp.compiler', + ], + }, + { + id: 'group-net', + label: 'Redes (Modelo OSI)', + icon: '🌐', + color: '#06b6d4', + nodeIds: [ + 'net.physical', 'net.ip', 'net.tcp-udp', 'net.sockets', 'net.dns', + ], + }, + { + id: 'group-web', + label: 'La Web', + icon: '🖧', + color: '#10b981', + nodeIds: [ + 'web.http', 'web.server', 'web.html-css', 'web.client', 'web.final', + ], + }, +]; function getLayoutedElements(nodes: Node[], edges: Edge[]) { const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); g.setGraph({ - rankdir: 'TB', // top to bottom - nodesep: 80, // horizontal spacing between nodes - ranksep: 120, // vertical spacing between ranks - edgesep: 40, // minimum spacing between edges + rankdir: 'TB', + nodesep: 80, + ranksep: 120, + edgesep: 40, marginx: 40, marginy: 40, }); @@ -62,6 +249,41 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) { return { nodes: layoutedNodes, edges }; } +/** Compute bounding box group nodes from layouted skill nodes */ +function computeGroupNodes(layoutedNodes: Node[]): Node[] { + return SKILL_GROUPS.map((group) => { + const memberNodes = layoutedNodes.filter((n) => group.nodeIds.includes(n.id)); + if (memberNodes.length === 0) return null; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of memberNodes) { + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + maxX = Math.max(maxX, n.position.x + NODE_WIDTH); + maxY = Math.max(maxY, n.position.y + NODE_HEIGHT); + } + + const width = maxX - minX + GROUP_PADDING * 2; + const height = maxY - minY + GROUP_PADDING * 2; + + return { + id: group.id, + type: 'skillGroup', + position: { x: minX - GROUP_PADDING, y: minY - GROUP_PADDING }, + draggable: false, + selectable: false, + style: { zIndex: -1 }, + data: { + label: group.label, + icon: group.icon, + color: group.color, + width, + height, + }, + } satisfies Node; + }).filter(Boolean) as Node[]; +} + export function SkillTreeCanvas() { const getNodeStatus = useProgressStore((s) => s.getNodeStatus); const completedChallenges = useProgressStore((s) => s.completedChallenges); @@ -72,23 +294,46 @@ export function SkillTreeCanvas() { setMounted(true); }, []); + // Dependency chain for the selected node + const depIds = useMemo(() => { + if (!selectedNodeId) return null; + const deps = getDependencyIds(selectedNodeId); + deps.add(selectedNodeId); // include the selected node itself in the highlighted set + return deps; + }, [selectedNodeId]); + const { nodes, edges } = useMemo(() => { - const rawNodes: Node[] = skillNodes.map((node) => ({ - id: node.id, - type: 'skillNode', - position: { x: 0, y: 0 }, // dagre will override - draggable: false, - data: { - ...node, - status: getNodeStatus(node.id), - }, - })); + const rawNodes: Node[] = skillNodes.map((node) => { + const isInPath = depIds ? depIds.has(node.id) : false; + const isSelected = node.id === selectedNodeId; + return { + id: node.id, + type: 'skillNode', + position: { x: 0, y: 0 }, + draggable: false, + data: { + ...node, + status: getNodeStatus(node.id), + dimmed: depIds ? !isInPath : false, + highlighted: isSelected, + }, + }; + }); const rawEdges: Edge[] = skillEdges.map((edge) => { const targetStatus = getNodeStatus(edge.to); const sourceStatus = getNodeStatus(edge.from); + + // Is this edge part of the dependency path? + const edgeInPath = depIds ? depIds.has(edge.from) && depIds.has(edge.to) : false; + const edgeDimmed = depIds ? !edgeInPath : false; + let strokeColor = '#444'; - if (sourceStatus === 'completed' && targetStatus !== 'locked') { + if (edgeInPath) { + strokeColor = '#6366f1'; // highlight path edges in primary + } else if (edgeDimmed) { + strokeColor = '#222'; + } else if (sourceStatus === 'completed' && targetStatus !== 'locked') { strokeColor = '#22c55e'; } else if (targetStatus === 'available') { strokeColor = '#6366f1'; @@ -101,20 +346,37 @@ export function SkillTreeCanvas() { source: edge.from, target: edge.to, type: 'bezier', - animated: targetStatus === 'available', + animated: edgeInPath || (!depIds && targetStatus === 'available'), style: { stroke: strokeColor, - strokeWidth: 2, + strokeWidth: edgeInPath ? 3 : 2, + opacity: edgeDimmed ? 0.15 : 1, + transition: 'all 0.3s ease', }, }; }); - return getLayoutedElements(rawNodes, rawEdges); + const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(rawNodes, rawEdges); + + // Create group background nodes from the layouted positions + const groupNodes = computeGroupNodes(layoutedNodes); + + // Groups go first (rendered behind), then skill nodes on top + return { + nodes: [...groupNodes, ...layoutedNodes], + edges: layoutedEdges, + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getNodeStatus, completedChallenges]); + }, [getNodeStatus, completedChallenges, depIds, selectedNodeId]); const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { - setSelectedNodeId(node.id); + if (node.type === 'skillGroup') return; + // Toggle: click same node deselects + setSelectedNodeId((prev) => prev === node.id ? null : node.id); + }, []); + + const onPaneClick = useCallback(() => { + setSelectedNodeId(null); }, []); if (!mounted) { @@ -131,10 +393,11 @@ export function SkillTreeCanvas() { nodes={nodes} edges={edges} onNodeClick={onNodeClick} + onPaneClick={onPaneClick} nodeTypes={nodeTypes} fitView fitViewOptions={{ padding: 0.4 }} - minZoom={0.3} + minZoom={0.2} maxZoom={1.5} nodesDraggable={false} proOptions={{ hideAttribution: true }} diff --git a/src/components/workbench/ExplanationRenderer.tsx b/src/components/workbench/ExplanationRenderer.tsx new file mode 100644 index 0000000..0b7984b --- /dev/null +++ b/src/components/workbench/ExplanationRenderer.tsx @@ -0,0 +1,102 @@ +'use client'; + +/** + * Renders explanation text, detecting ASCII truth tables and rendering them + * as styled HTML tables. + * + * Detects lines with "|" separators as table rows. + * A group of consecutive "|" lines becomes one table. + */ +export function ExplanationRenderer({ text }: { text: string }) { + const lines = text.split('\n'); + const blocks: Array<{ type: 'text'; content: string } | { type: 'table'; rows: string[][] }> = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + // Detect table: line contains | and has content on both sides + if (isTableLine(line)) { + const tableRows: string[][] = []; + while (i < lines.length && isTableLine(lines[i])) { + const cells = lines[i] + .split('|') + .map((c) => c.trim()) + .filter((c) => c.length > 0); + tableRows.push(cells); + i++; + } + blocks.push({ type: 'table', rows: tableRows }); + } else { + // Accumulate text lines + const textLines: string[] = []; + while (i < lines.length && !isTableLine(lines[i])) { + textLines.push(lines[i]); + i++; + } + const content = textLines.join('\n').trim(); + if (content) blocks.push({ type: 'text', content }); + } + } + + return ( +
+ {blocks.map((block, idx) => { + if (block.type === 'text') { + return ( +

+ {block.content} +

+ ); + } + + // Render table + const [header, ...body] = block.rows; + const hasHeader = header && body.length > 0; + + return ( +
+ + {hasHeader && ( + + + {header.map((cell, j) => ( + + ))} + + + )} + + {(hasHeader ? body : block.rows).map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {cell} +
+ {cell} +
+
+ ); + })} +
+ ); +} + +function isTableLine(line: string): boolean { + if (!line) return false; + const trimmed = line.trim(); + // Must contain | and have actual content (not just dashes/separators) + if (!trimmed.includes('|')) return false; + // Filter out lines that are just separators like "---|---|---" + const withoutSep = trimmed.replace(/[-|+\s]/g, ''); + return withoutSep.length > 0; +} diff --git a/src/components/workbench/WorkbenchShell.tsx b/src/components/workbench/WorkbenchShell.tsx index d1510bf..d144169 100644 --- a/src/components/workbench/WorkbenchShell.tsx +++ b/src/components/workbench/WorkbenchShell.tsx @@ -11,7 +11,10 @@ import { useProgressStore } from '@/stores/useProgressStore'; import { getChallengesForNode } from '@/data/challenges/math'; import { MathInput } from './modules/MathInput'; import { MultipleChoice } from './modules/MultipleChoice'; +import { CircuitBuilder } from './modules/circuit-builder/CircuitBuilder'; +import { ElectronicsLab } from './modules/electronics/ElectronicsLab'; import { Scratchpad } from './Scratchpad'; +import { ExplanationRenderer } from './ExplanationRenderer'; const MAX_ATTEMPTS_BEFORE_REVEAL = 3; @@ -42,7 +45,14 @@ export function WorkbenchShell({ challenge }: WorkbenchShellProps) { const [elapsedTime, setElapsedTime] = useState(0); const [canProceed, setCanProceed] = useState(false); const shakeTimeout = useRef>(null); - const [showScratchpad, setShowScratchpad] = useState(true); + const [showScratchpad, setShowScratchpad] = useState(() => { + if (typeof window === 'undefined') return false; + return localStorage.getItem('scratchpad-open') === 'true'; + }); + + useEffect(() => { + localStorage.setItem('scratchpad-open', showScratchpad ? 'true' : 'false'); + }, [showScratchpad]); const [showExplanation, setShowExplanation] = useState(!!challenge.explanation); const isAlreadyCompleted = !!completedChallenges[challenge.id]; @@ -254,9 +264,7 @@ export function WorkbenchShell({ challenge }: WorkbenchShellProps) { {showExplanation && (
-
- {challenge.explanation} -
+
)}
@@ -287,6 +295,20 @@ export function WorkbenchShell({ challenge }: WorkbenchShellProps) { disabled={done || phase === 'wrong-shake'} /> )} + {challenge.content.type === 'circuit-builder' && ( + setAnswer(JSON.stringify(circuit))} + disabled={done || phase === 'wrong-shake'} + /> + )} + {challenge.content.type === 'electronics-lab' && ( + setAnswer(JSON.stringify(circuit))} + disabled={done || phase === 'wrong-shake'} + /> + )} {/* Wrong attempt feedback inline */} {phase === 'wrong-shake' && (

diff --git a/src/components/workbench/modules/circuit-builder/CircuitBuilder.tsx b/src/components/workbench/modules/circuit-builder/CircuitBuilder.tsx new file mode 100644 index 0000000..3ef6727 --- /dev/null +++ b/src/components/workbench/modules/circuit-builder/CircuitBuilder.tsx @@ -0,0 +1,377 @@ +'use client'; + +import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { CircuitBuilderContent, CircuitState, GateType, PlacedGate, Wire, gateInputCount } from '@/types/circuit'; +import { GateComponent, GATE_W, GATE_H, getInputPortPositions, getOutputPortPosition } from './GateComponent'; +import { simulateCircuit } from './simulateCircuit'; +import { Trash2, Maximize2, Minimize2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface CircuitBuilderProps { + content: CircuitBuilderContent; + onCircuitChange: (circuit: CircuitState) => void; + disabled?: boolean; +} + +const CANVAS_W = 700; +const CANVAS_H = 500; +const INPUT_X = 30; +const OUTPUT_X = CANVAS_W - 30; + +interface PortPos { + id: string; + x: number; + y: number; +} + +let nextGateId = 1; + +export function CircuitBuilder({ content, onCircuitChange, disabled }: CircuitBuilderProps) { + const [gates, setGates] = useState([]); + const [wires, setWires] = useState([]); + const [selectedGate, setSelectedGate] = useState(null); + const [placingGate, setPlacingGate] = useState(null); + const [wiringFrom, setWiringFrom] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + const [dragging, setDragging] = useState<{ id: string; offsetX: number; offsetY: number } | null>(null); + const [fullscreen, setFullscreen] = useState(false); + const svgRef = useRef(null); + + useEffect(() => { + if (!fullscreen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setFullscreen(false); } + }; + window.addEventListener('keydown', handleKey, true); + return () => window.removeEventListener('keydown', handleKey, true); + }, [fullscreen]); + + const circuit: CircuitState = useMemo(() => ({ gates, wires }), [gates, wires]); + + // Port positions for circuit inputs/outputs + const inputPorts: PortPos[] = content.inputLabels.map((_, i) => ({ + id: `input-${i}`, + x: INPUT_X, + y: 80 + i * 80, + })); + const outputPorts: PortPos[] = content.outputLabels.map((_, i) => ({ + id: `output-${i}`, + x: OUTPUT_X, + y: 80 + i * 80, + })); + + // Get all port positions for wire rendering + const allPortPositions = useMemo(() => { + const map = new Map(); + inputPorts.forEach((p) => map.set(p.id, { x: p.x, y: p.y })); + outputPorts.forEach((p) => map.set(p.id, { x: p.x, y: p.y })); + gates.forEach((gate) => { + const inPorts = getInputPortPositions(gate.type); + inPorts.forEach((pos, i) => map.set(`${gate.id}:in-${i}`, { x: gate.x + pos.x, y: gate.y + pos.y })); + const outPos = getOutputPortPosition(); + map.set(`${gate.id}:out`, { x: gate.x + outPos.x, y: gate.y + outPos.y }); + }); + return map; + }, [gates, inputPorts, outputPorts]); + + // Simulate with all-false to show signal flow + const simResults = useMemo(() => { + const results: Map = new Map(); + // Simulate each truth table row to check pass/fail + return results; + }, []); + + // Truth table evaluation + const truthTableResults = useMemo(() => { + return content.truthTable.map((row) => { + const result = simulateCircuit(circuit, row.inputs); + if (typeof result === 'string') return { expected: row.outputs, actual: null, pass: false }; + const pass = result.every((v, i) => v === row.outputs[i]); + return { expected: row.outputs, actual: result, pass }; + }); + }, [circuit, content.truthTable]); + + const getSvgPoint = useCallback((e: React.MouseEvent) => { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const ctm = svg.getScreenCTM(); + if (ctm) { + return { + x: (e.clientX - ctm.e) / ctm.a, + y: (e.clientY - ctm.f) / ctm.d, + }; + } + const rect = svg.getBoundingClientRect(); + return { + x: ((e.clientX - rect.left) / rect.width) * CANVAS_W, + y: ((e.clientY - rect.top) / rect.height) * CANVAS_H, + }; + }, []); + + const syncCircuit = useCallback((g: PlacedGate[], w: Wire[]) => { + onCircuitChange({ gates: g, wires: w }); + }, [onCircuitChange]); + + const handleCanvasClick = useCallback((e: React.MouseEvent) => { + if (disabled) return; + const pos = getSvgPoint(e); + + if (placingGate) { + const newGate: PlacedGate = { + id: `gate-${nextGateId++}`, + type: placingGate, + x: pos.x - GATE_W / 2, + y: pos.y - GATE_H / 2, + }; + const newGates = [...gates, newGate]; + setGates(newGates); + syncCircuit(newGates, wires); + setPlacingGate(null); + return; + } + + // Deselect + setSelectedGate(null); + setWiringFrom(null); + }, [disabled, placingGate, gates, wires, getSvgPoint, syncCircuit]); + + // Determine if a port is a "source" (signal producer) or "sink" (signal consumer) + const isSourcePort = useCallback((portId: string) => { + return portId.includes(':out') || portId.startsWith('input-'); + }, []); + + // Start dragging a wire from any port + const handlePortDragStart = useCallback((portId: string, pos: { x: number; y: number }) => { + if (disabled) return; + setWiringFrom({ id: portId, x: pos.x, y: pos.y }); + }, [disabled]); + + // Complete wire when releasing on a port + const handlePortDragEnd = useCallback((portId: string, _pos: { x: number; y: number }) => { + if (disabled || !wiringFrom) return; + if (portId === wiringFrom.id) { setWiringFrom(null); return; } // same port + + let from = wiringFrom.id; + let to = portId; + + // Auto-detect direction: one must be source, other must be sink + const fromIsSource = isSourcePort(from); + const toIsSource = isSourcePort(to); + + // If both are same type, can't connect + if (fromIsSource === toIsSource) { setWiringFrom(null); return; } + + // Normalize: from = source (output/circuit-input), to = sink (input/circuit-output) + if (!fromIsSource && toIsSource) { + [from, to] = [to, from]; + } + + // Don't allow duplicate wires to same sink + const existing = wires.find((w) => w.to === to); + if (!existing) { + const newWire: Wire = { id: `wire-${Date.now()}`, from, to }; + const newWires = [...wires, newWire]; + setWires(newWires); + syncCircuit(gates, newWires); + } + + setWiringFrom(null); + }, [disabled, wiringFrom, wires, gates, syncCircuit, isSourcePort]); + + const handleGateMouseDown = useCallback((id: string, e: React.MouseEvent) => { + if (disabled) return; + setSelectedGate(id); + const gate = gates.find((g) => g.id === id); + if (!gate) return; + const pos = getSvgPoint(e); + setDragging({ id, offsetX: pos.x - gate.x, offsetY: pos.y - gate.y }); + }, [disabled, gates, getSvgPoint]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + const pos = getSvgPoint(e); + setMousePos(pos); + + if (dragging) { + const newGates = gates.map((g) => + g.id === dragging.id + ? { ...g, x: pos.x - dragging.offsetX, y: pos.y - dragging.offsetY } + : g + ); + setGates(newGates); + syncCircuit(newGates, wires); + } + }, [dragging, gates, wires, getSvgPoint, syncCircuit]); + + const handleMouseUp = useCallback(() => { + setDragging(null); + // Cancel wiring if released on empty space + if (wiringFrom) setWiringFrom(null); + }, [wiringFrom]); + + const deleteGate = useCallback((id: string) => { + if (disabled) return; + const newGates = gates.filter((g) => g.id !== id); + const newWires = wires.filter((w) => !w.from.startsWith(id) && !w.to.startsWith(id)); + setGates(newGates); + setWires(newWires); + syncCircuit(newGates, newWires); + if (selectedGate === id) setSelectedGate(null); + }, [disabled, gates, wires, selectedGate, syncCircuit]); + + const clearAll = useCallback(() => { + setGates([]); + setWires([]); + setSelectedGate(null); + setWiringFrom(null); + syncCircuit([], []); + }, [syncCircuit]); + + // Render wire bezier + function renderWire(from: { x: number; y: number }, to: { x: number; y: number }, color: string, key: string, isPreview = false) { + const dx = Math.max(Math.abs(to.x - from.x) * 0.5, 30); + const d = `M${from.x},${from.y} C${from.x + dx},${from.y} ${to.x - dx},${to.y} ${to.x},${to.y}`; + return ; + } + + // Shared gate palette + const paletteContent = ( + <> + {content.availableGates.map((type) => ( + + ))} +

+ + + ); + + // Shared SVG canvas + const svgCanvas = (fs: boolean) => ( + + + + + + + + {inputPorts.map((port, i) => ( + + { e.stopPropagation(); handlePortDragStart(port.id, { x: port.x, y: port.y }); }} + onMouseUp={(e) => { e.stopPropagation(); handlePortDragEnd(port.id, { x: port.x, y: port.y }); }} /> + {content.inputLabels[i]} + + ))} + {outputPorts.map((port, i) => ( + + { e.stopPropagation(); handlePortDragStart(port.id, { x: port.x, y: port.y }); }} + onMouseUp={(e) => { e.stopPropagation(); handlePortDragEnd(port.id, { x: port.x, y: port.y }); }} /> + {content.outputLabels[i]} + + ))} + {wires.map((wire) => { const from = allPortPositions.get(wire.from); const to = allPortPositions.get(wire.to); if (!from || !to) return null; return renderWire(from, to, '#64748b', wire.id); })} + {wiringFrom && mousePos && renderWire(wiringFrom, mousePos, '#6366f1', 'wiring-preview', true)} + {placingGate && mousePos && ( + + )} + {gates.map((gate) => ( + + ))} + + ); + + // Shared truth table + const truthTable = ( +
+ + + + {content.inputLabels.map((l) => )} + + {content.outputLabels.map((l) => )} + + + + + {content.truthTable.map((row, i) => { + const result = truthTableResults[i]; + return ( + + {row.inputs.map((v, j) => )} + + {row.outputs.map((v, j) => )} + + + + ); + })} + +
{l}{l}Tu resultado +
{v ? '1' : '0'}{v ? '1' : '0'}{result?.actual ? result.actual.map((v) => v ? '1' : '0').join(', ') : }{result?.actual && (result.pass ? '✓' : '✗')}
+
+ ); + + // Fullscreen button + const fsButton = (fs: boolean) => ( + + ); + + // === FULLSCREEN PORTAL === + const fullscreenOverlay = fullscreen && createPortal( +
+
+

Puertas

+ {paletteContent} +
+
+
+ {fsButton(true)} +
+
{svgCanvas(true)}
+
{truthTable}
+
+
, + document.body + ); + + // === INLINE === + return ( +
+
+
+
+

Puertas

+ {fsButton(false)} +
+ {paletteContent} +
+
+ {svgCanvas(false)} +
+
+ {truthTable} +
+

Arrastra desde una salida/entrada a otra para conectar. Clic derecho para eliminar.

+
+ {fullscreenOverlay} +
+ ); +} diff --git a/src/components/workbench/modules/circuit-builder/GateComponent.tsx b/src/components/workbench/modules/circuit-builder/GateComponent.tsx new file mode 100644 index 0000000..1b9bf02 --- /dev/null +++ b/src/components/workbench/modules/circuit-builder/GateComponent.tsx @@ -0,0 +1,138 @@ +import { GateType, gateInputCount } from '@/types/circuit'; + +export const GATE_W = 80; +export const GATE_H = 60; +export const PORT_R = 6; + +interface GateComponentProps { + type: GateType; + x: number; + y: number; + id: string; + selected?: boolean; + signalOut?: boolean; + onPortDragStart?: (portId: string, pos: { x: number; y: number }) => void; + onPortDragEnd?: (portId: string, pos: { x: number; y: number }) => void; + onGateMouseDown?: (id: string, e: React.MouseEvent) => void; + onGateDelete?: (id: string) => void; +} + +// Gate body shapes as SVG paths +function gateBody(type: GateType) { + switch (type) { + case 'AND': + return ; + case 'OR': + return ; + case 'NOT': + return ( + + + + + ); + case 'NAND': + return ( + + + + + ); + case 'NOR': + return ( + + + + + ); + case 'XOR': + return ( + + + + + ); + case 'XNOR': + return ( + + + + + + ); + } +} + +export function getInputPortPositions(type: GateType): { x: number; y: number }[] { + const count = gateInputCount(type); + if (count === 1) return [{ x: 0, y: GATE_H / 2 }]; + return [ + { x: 0, y: GATE_H * 0.25 }, + { x: 0, y: GATE_H * 0.75 }, + ]; +} + +export function getOutputPortPosition(): { x: number; y: number } { + return { x: GATE_W, y: GATE_H / 2 }; +} + +export function GateComponent({ + type, x, y, id, selected, signalOut, + onPortDragStart, onPortDragEnd, onGateMouseDown, onGateDelete, +}: GateComponentProps) { + const inputs = getInputPortPositions(type); + const output = getOutputPortPosition(); + + return ( + + {/* Hit area for drag */} + { e.stopPropagation(); onGateMouseDown?.(id, e); }} + onContextMenu={(e) => { e.preventDefault(); onGateDelete?.(id); }} + style={{ cursor: 'move' }} + /> + + {/* Body */} + {gateBody(type)} + + {/* Label */} + + {type} + + + {/* Selection highlight */} + {selected && ( + + )} + + {/* Input ports */} + {inputs.map((pos, i) => ( + { e.stopPropagation(); onPortDragStart?.(`${id}:in-${i}`, { x: x + pos.x, y: y + pos.y }); }} + onMouseUp={(e) => { e.stopPropagation(); onPortDragEnd?.(`${id}:in-${i}`, { x: x + pos.x, y: y + pos.y }); }} + /> + ))} + + {/* Output port */} + { e.stopPropagation(); onPortDragStart?.(`${id}:out`, { x: x + output.x, y: y + output.y }); }} + onMouseUp={(e) => { e.stopPropagation(); onPortDragEnd?.(`${id}:out`, { x: x + output.x, y: y + output.y }); }} + /> + + ); +} diff --git a/src/components/workbench/modules/circuit-builder/simulateCircuit.ts b/src/components/workbench/modules/circuit-builder/simulateCircuit.ts new file mode 100644 index 0000000..8d8f9f6 --- /dev/null +++ b/src/components/workbench/modules/circuit-builder/simulateCircuit.ts @@ -0,0 +1,85 @@ +import { CircuitState, evaluateGate, gateInputCount } from '@/types/circuit'; + +/** + * Simulate a circuit with given input values. + * Returns output values or an error string. + */ +export function simulateCircuit( + circuit: CircuitState, + inputValues: boolean[] +): boolean[] | string { + // Map: portId -> boolean value + const signals = new Map(); + + // Seed circuit inputs + inputValues.forEach((val, i) => { + signals.set(`input-${i}`, val); + }); + + // Build a map of wires: toPort -> fromPort + const wireSource = new Map(); + for (const wire of circuit.wires) { + wireSource.set(wire.to, wire.from); + } + + // Topological evaluation: iterate until stable or stuck + const maxIterations = circuit.gates.length + 1; + let resolved = 0; + const gateResolved = new Set(); + + for (let iter = 0; iter < maxIterations; iter++) { + let progress = false; + + for (const gate of circuit.gates) { + if (gateResolved.has(gate.id)) continue; + + const numInputs = gateInputCount(gate.type); + const inputVals: boolean[] = []; + let allResolved = true; + + for (let i = 0; i < numInputs; i++) { + const portId = `${gate.id}:in-${i}`; + const source = wireSource.get(portId); + if (!source || !signals.has(source)) { + allResolved = false; + break; + } + inputVals.push(signals.get(source)!); + } + + if (allResolved) { + const output = evaluateGate(gate.type, inputVals); + signals.set(`${gate.id}:out`, output); + gateResolved.add(gate.id); + resolved++; + progress = true; + } + } + + if (!progress) break; + } + + if (gateResolved.size < circuit.gates.length) { + return 'Hay puertas sin conectar o con conexiones incompletas'; + } + + // Read circuit outputs + const outputs: boolean[] = []; + let i = 0; + while (true) { + const outPort = `output-${i}`; + const source = wireSource.get(outPort); + if (source === undefined) break; + if (!signals.has(source)) { + return `La salida ${i} no está conectada`; + } + outputs.push(signals.get(source)!); + i++; + } + + if (outputs.length === 0) { + return 'No hay salidas conectadas'; + } + + return outputs; +} diff --git a/src/components/workbench/modules/electronics/ComponentSVG.tsx b/src/components/workbench/modules/electronics/ComponentSVG.tsx new file mode 100644 index 0000000..0a16051 --- /dev/null +++ b/src/components/workbench/modules/electronics/ComponentSVG.tsx @@ -0,0 +1,276 @@ +import { ElectronicComponent, ElectronicComponentType, getTerminals } from '@/types/electronics'; + +export const COMP_W = 80; +export const COMP_H = 60; +const TERM_R = 7; + +interface ComponentSVGProps { + component: ElectronicComponent; + selected?: boolean; + voltage?: Map; + meterReading?: { value: number; unit: string }; + onMouseDown?: (id: string, e: React.MouseEvent) => void; + onDelete?: (id: string) => void; + onTerminalDragStart?: (termId: string, pos: { x: number; y: number }) => void; + onTerminalDragEnd?: (termId: string, pos: { x: number; y: number }) => void; +} + +/** Get terminal positions in world coords for a placed component */ +export function getTerminalPositions( + type: ElectronicComponentType, + x: number, + y: number, + rotation: number +): Record { + const localPositions = getLocalTerminalPositions(type); + const result: Record = {}; + const cx = x + COMP_W / 2; + const cy = y + COMP_H / 2; + + for (const [name, pos] of Object.entries(localPositions)) { + const rotated = rotatePoint(pos.x - COMP_W / 2, pos.y - COMP_H / 2, rotation); + result[name] = { x: cx + rotated.x, y: cy + rotated.y }; + } + return result; +} + +function getLocalTerminalPositions(type: ElectronicComponentType): Record { + switch (type) { + case 'voltage-source': + return { pos: { x: COMP_W / 2, y: 0 }, neg: { x: COMP_W / 2, y: COMP_H } }; + case 'resistor': + return { a: { x: 0, y: COMP_H / 2 }, b: { x: COMP_W, y: COMP_H / 2 } }; + case 'capacitor': + return { a: { x: 0, y: COMP_H / 2 }, b: { x: COMP_W, y: COMP_H / 2 } }; + case 'led': + return { anode: { x: 0, y: COMP_H / 2 }, cathode: { x: COMP_W, y: COMP_H / 2 } }; + case 'switch': + return { a: { x: 0, y: COMP_H / 2 }, b: { x: COMP_W, y: COMP_H / 2 } }; + case 'ground': + return { gnd: { x: COMP_W / 2, y: 0 } }; + case 'nmos': + case 'pmos': + return { + gate: { x: 0, y: COMP_H / 2 }, + drain: { x: COMP_W, y: COMP_H * 0.2 }, + source: { x: COMP_W, y: COMP_H * 0.8 }, + }; + case 'voltmeter': + return { pos: { x: 0, y: COMP_H / 2 }, neg: { x: COMP_W, y: COMP_H / 2 } }; + case 'ammeter': + return { a: { x: 0, y: COMP_H / 2 }, b: { x: COMP_W, y: COMP_H / 2 } }; + } +} + +function rotatePoint(x: number, y: number, angle: number): { x: number; y: number } { + const rad = (angle * Math.PI) / 180; + return { + x: x * Math.cos(rad) - y * Math.sin(rad), + y: x * Math.sin(rad) + y * Math.cos(rad), + }; +} + +function componentBody(type: ElectronicComponentType, value?: number) { + switch (type) { + case 'voltage-source': + return ( + + + + + + {value ?? 5}V + + ); + case 'resistor': + return ( + + + {formatResistance(value ?? 1000)} + + ); + case 'capacitor': + return ( + + {/* Two parallel plates */} + + + + + {formatCapacitance(value ?? 100)} + + ); + case 'led': + return ( + + + + + + {/* Light rays */} + + + + ); + case 'switch': + return ( + + + + + + + SW + + ); + case 'ground': + return ( + + + + + + + ); + case 'nmos': + return ( + + {/* Gate line */} + + + {/* Channel */} + + {/* Drain */} + + + + {/* Source */} + + + + {/* Arrow on source */} + + NMOS + + ); + case 'pmos': + return ( + + + + + + + + + + + + + PMOS + + ); + case 'voltmeter': + return ( + + + V + + + {/* + and - labels */} + + + + + ); + case 'ammeter': + return ( + + + A + + + + ); + } +} + +function formatResistance(ohms: number): string { + if (ohms >= 1e6) return `${ohms / 1e6}MΩ`; + if (ohms >= 1e3) return `${ohms / 1e3}kΩ`; + return `${ohms}Ω`; +} + +function formatCapacitance(uf: number): string { + if (uf >= 1000) return `${uf / 1000}mF`; + if (uf >= 1) return `${uf}µF`; + if (uf >= 0.001) return `${uf * 1000}nF`; + return `${uf * 1e6}pF`; +} + +export function ComponentSVG({ + component, selected, voltage, meterReading, onMouseDown, onDelete, onTerminalDragStart, onTerminalDragEnd, +}: ComponentSVGProps) { + const { id, type, x, y, rotation } = component; + const terminals = getTerminalPositions(type, x, y, rotation); + const isMeter = type === 'voltmeter' || type === 'ammeter'; + + return ( + + {/* Meter reading display — always centered above the component regardless of rotation */} + {isMeter && meterReading && ( + + + + {meterReading.value.toFixed(meterReading.unit === 'mA' ? 1 : 2)} {meterReading.unit} + + + )} + + {/* Rotated body */} + + {/* Hit area */} + { e.stopPropagation(); onMouseDown?.(id, e); }} + onContextMenu={(e) => { e.preventDefault(); onDelete?.(id); }} + style={{ cursor: 'move' }} + /> + {componentBody(type, component.value)} + {selected && ( + + )} + + + {/* Terminals (in world coords, not rotated) */} + {Object.entries(terminals).map(([name, pos]) => { + const termId = `${id}:${name}`; + const v = voltage?.get(termId); + const color = v !== undefined ? (v > 3 ? '#ef4444' : v > 0.5 ? '#f59e0b' : '#555') : '#64748b'; + return ( + + { e.stopPropagation(); onTerminalDragStart?.(termId, pos); }} + onMouseUp={(e) => { e.stopPropagation(); onTerminalDragEnd?.(termId, pos); }} + /> + {v !== undefined && ( + + {v}V + + )} + + ); + })} + + ); +} diff --git a/src/components/workbench/modules/electronics/ElectronicsLab.tsx b/src/components/workbench/modules/electronics/ElectronicsLab.tsx new file mode 100644 index 0000000..7889571 --- /dev/null +++ b/src/components/workbench/modules/electronics/ElectronicsLab.tsx @@ -0,0 +1,453 @@ +'use client'; + +import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { + ElectronicsContent, + ElectronicCircuitState, + ElectronicComponent, + ElectronicWire, + ElectronicComponentType, + getTerminals, + componentLabel, +} from '@/types/electronics'; +import { simulateElectronics } from './simulateElectronics'; +import { ComponentSVG, COMP_W, COMP_H, getTerminalPositions } from './ComponentSVG'; +import { Trash2, RotateCw, Play, Pause, Maximize2, Minimize2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface ElectronicsLabProps { + content: ElectronicsContent; + onCircuitChange: (state: ElectronicCircuitState) => void; + disabled?: boolean; +} + +const CANVAS_W = 700; +const CANVAS_H = 500; + +interface TermPos { id: string; x: number; y: number } + +let nextCompId = 1; + +export function ElectronicsLab({ content, onCircuitChange, disabled }: ElectronicsLabProps) { + const [components, setComponents] = useState(content.preplacedComponents ?? []); + const [wires, setWires] = useState(content.preplacedWires ?? []); + const [placing, setPlacing] = useState(null); + const [wiringFrom, setWiringFrom] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + const [dragging, setDragging] = useState<{ id: string; ox: number; oy: number } | null>(null); + const [selected, setSelected] = useState(null); + const [running, setRunning] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const didDrag = useRef(false); + const svgRef = useRef(null); + + // Escape exits fullscreen + useEffect(() => { + if (!fullscreen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setFullscreen(false); } + }; + window.addEventListener('keydown', handleKey, true); + return () => window.removeEventListener('keydown', handleKey, true); + }, [fullscreen]); + + const circuit: ElectronicCircuitState = useMemo(() => ({ components, wires }), [components, wires]); + const simResult = useMemo(() => { + if (!running) return null; + return simulateElectronics(circuit); + }, [circuit, running]); + + const sync = useCallback((c: ElectronicComponent[], w: ElectronicWire[]) => { + onCircuitChange({ components: c, wires: w }); + }, [onCircuitChange]); + + const getSvgPoint = useCallback((e: React.MouseEvent) => { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const ctm = svg.getScreenCTM(); + if (ctm) { + return { + x: (e.clientX - ctm.e) / ctm.a, + y: (e.clientY - ctm.f) / ctm.d, + }; + } + // Fallback + const rect = svg.getBoundingClientRect(); + return { + x: ((e.clientX - rect.left) / rect.width) * CANVAS_W, + y: ((e.clientY - rect.top) / rect.height) * CANVAS_H, + }; + }, []); + + // Get all terminal world positions + const allTerminals = useMemo(() => { + const map = new Map(); + for (const comp of components) { + const terminals = getTerminalPositions(comp.type, comp.x, comp.y, comp.rotation); + for (const [name, pos] of Object.entries(terminals)) { + map.set(`${comp.id}:${name}`, pos); + } + } + return map; + }, [components]); + + const handleCanvasClick = useCallback((e: React.MouseEvent) => { + if (disabled) return; + const pos = getSvgPoint(e); + + if (placing) { + const newComp: ElectronicComponent = { + id: `comp-${nextCompId++}`, + type: placing, + x: pos.x - COMP_W / 2, + y: pos.y - COMP_H / 2, + rotation: 0, + value: placing === 'voltage-source' ? 5 : placing === 'resistor' ? 1000 : placing === 'capacitor' ? 100 : undefined, + }; + const nc = [...components, newComp]; + setComponents(nc); + sync(nc, wires); + setPlacing(null); + return; + } + + // Don't deselect if we just finished dragging a component + if (!didDrag.current) { + setSelected(null); + } + didDrag.current = false; + setWiringFrom(null); + }, [disabled, placing, components, wires, getSvgPoint, sync]); + + const handleTerminalDragStart = useCallback((termId: string, pos: { x: number; y: number }) => { + if (disabled) return; + setWiringFrom({ id: termId, ...pos }); + }, [disabled]); + + const handleTerminalDragEnd = useCallback((termId: string) => { + if (disabled || !wiringFrom) return; + if (termId === wiringFrom.id) { setWiringFrom(null); return; } + + const existing = wires.find((w) => + (w.from === wiringFrom.id && w.to === termId) || + (w.from === termId && w.to === wiringFrom.id) + ); + if (!existing) { + const newWire: ElectronicWire = { id: `ew-${Date.now()}`, from: wiringFrom.id, to: termId }; + const nw = [...wires, newWire]; + setWires(nw); + sync(components, nw); + } + setWiringFrom(null); + }, [disabled, wiringFrom, wires, components, sync]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + const pos = getSvgPoint(e); + setMousePos(pos); + if (dragging) { + didDrag.current = true; + const nc = components.map((c) => + c.id === dragging.id ? { ...c, x: pos.x - dragging.ox, y: pos.y - dragging.oy } : c + ); + setComponents(nc); + sync(nc, wires); + } + }, [dragging, components, wires, getSvgPoint, sync]); + + const handleMouseUp = useCallback(() => { + setDragging(null); + if (wiringFrom) setWiringFrom(null); + }, [wiringFrom]); + + const handleCompMouseDown = useCallback((id: string, e: React.MouseEvent) => { + if (disabled) return; + // Mark as interacted so canvas click doesn't deselect + didDrag.current = true; + setSelected(id); + const comp = components.find((c) => c.id === id); + if (!comp) return; + const pos = getSvgPoint(e); + setDragging({ id, ox: pos.x - comp.x, oy: pos.y - comp.y }); + }, [disabled, components, getSvgPoint]); + + const deleteComp = useCallback((id: string) => { + if (disabled) return; + const nc = components.filter((c) => c.id !== id); + const nw = wires.filter((w) => !w.from.startsWith(id) && !w.to.startsWith(id)); + setComponents(nc); + setWires(nw); + sync(nc, nw); + if (selected === id) setSelected(null); + }, [disabled, components, wires, selected, sync]); + + const rotateComp = useCallback((id: string) => { + if (disabled) return; + const nc = components.map((c) => + c.id === id ? { ...c, rotation: ((c.rotation + 90) % 360) as 0 | 90 | 180 | 270 } : c + ); + setComponents(nc); + sync(nc, wires); + }, [disabled, components, wires, sync]); + + const changeValue = useCallback((id: string, value: number) => { + const nc = components.map((c) => c.id === id ? { ...c, value } : c); + setComponents(nc); + sync(nc, wires); + }, [components, wires, sync]); + + const clearAll = useCallback(() => { + const preplaced = content.preplacedComponents ?? []; + const preWires = content.preplacedWires ?? []; + setComponents(preplaced); + setWires(preWires); + setSelected(null); + setWiringFrom(null); + sync(preplaced, preWires); + }, [content, sync]); + + function renderWire(from: { x: number; y: number }, to: { x: number; y: number }, color: string, key: string, dashed = false) { + const dx = Math.max(Math.abs(to.x - from.x) * 0.4, 20); + const d = `M${from.x},${from.y} C${from.x + dx},${from.y} ${to.x - dx},${to.y} ${to.x},${to.y}`; + return ; + } + + // Color wire by voltage difference + function wireColor(wireObj: ElectronicWire): string { + if (!simResult?.success) return '#555'; + const vFrom = simResult.nodeVoltages.get(wireObj.from) ?? 0; + const vTo = simResult.nodeVoltages.get(wireObj.to) ?? 0; + const avg = (vFrom + vTo) / 2; + if (avg > 3) return '#ef4444'; // high voltage = red + if (avg > 0.5) return '#f59e0b'; // medium = amber + return '#555'; // low/ground = gray + } + + // Shared palette content + const paletteContent = ( + <> + {content.availableComponents.map((type) => ( + + ))} +
+ {selected && ( +
+ + {components.find((c) => c.id === selected)?.type === 'resistor' && ( +
+ + c.id === selected)?.value ?? 1000} + onChange={(e) => changeValue(selected, parseFloat(e.target.value) || 1000)} + onKeyDown={(e) => e.stopPropagation()} /> +
+ )} + {components.find((c) => c.id === selected)?.type === 'voltage-source' && ( +
+ + c.id === selected)?.value ?? 5} + onChange={(e) => changeValue(selected, parseFloat(e.target.value) || 5)} + onKeyDown={(e) => e.stopPropagation()} /> +
+ )} + {components.find((c) => c.id === selected)?.type === 'capacitor' && ( +
+ + c.id === selected)?.value ?? 100} + onChange={(e) => changeValue(selected, parseFloat(e.target.value) || 100)} + onKeyDown={(e) => e.stopPropagation()} /> +
+ )} + +
+ )} +
+ + + ); + + // Shared SVG canvas + const svgCanvas = (fs: boolean) => ( + + + + + + + + {wires.map((wire) => { + const from = allTerminals.get(wire.from); + const to = allTerminals.get(wire.to); + if (!from || !to) return null; + return renderWire(from, to, wireColor(wire), wire.id); + })} + {wiringFrom && mousePos && renderWire(wiringFrom, mousePos, '#6366f1', 'wire-preview', true)} + {placing && mousePos && ( + + )} + {components.map((comp) => ( + + ))} + + ); + + // Shared probe table + const probeTable = content.probes.length > 0 && ( +
+ + + + + + + + + + {content.probes.map((probe, i) => { + // Find meter readings of the matching type + const readings: number[] = []; + if (simResult?.success) { + for (const [, reading] of simResult.meterReadings) { + if ((probe.type === 'voltmeter' && reading.unit === 'V') || + (probe.type === 'ammeter' && reading.unit === 'mA')) { + readings.push(reading.value); + } + } + } + const match = readings.find((r) => Math.abs(r - probe.expected) <= probe.tolerance); + const hasReading = readings.length > 0; + const pass = match !== undefined; + const instrumentName = probe.type === 'voltmeter' ? 'Voltímetro' : 'Amperímetro'; + return ( + + + + + + + ); + })} + +
MediciónEsperadoTu resultado +
+ {instrumentName}:{' '} + {probe.label} + {probe.expected}{probe.unit} + {hasReading + ? readings.map((r) => r.toFixed(1) + probe.unit).join(', ') + : Coloca un {instrumentName.toLowerCase()} + } + {hasReading && (pass ? '✓' : '✗')}
+
+ ); + + // Play/pause + fullscreen toolbar + const toolbar = (fs: boolean) => ( +
+ + + {running ? '● Simulación activa' : '○ Pausada'} + + +
+ ); + + // === FULLSCREEN PORTAL === + const fullscreenOverlay = fullscreen && createPortal( +
+ {/* Sidebar palette */} +
+
+

Componentes

+
+ {paletteContent} +
+ + {/* Main area */} +
+
+ {toolbar(true)} +
+
+ {svgCanvas(true)} +
+
+ {probeTable} + {simResult && !simResult.success && simResult.error && components.length > 0 && ( +

{simResult.error}

+ )} +
+
+
, + document.body + ); + + // === INLINE (normal) === + return ( +
+
+
+

Componentes

+ {paletteContent} +
+
+ {toolbar(false)} +
+ {svgCanvas(false)} +
+
+
+ {probeTable} + {simResult && !simResult.success && simResult.error && components.length > 0 && ( +

{simResult.error}

+ )} +
+

Arrastra desde un terminal (●) a otro para conectar. Clic derecho para eliminar.

+

Selecciona un componente para rotarlo o cambiar su valor.

+
+ {fullscreenOverlay} +
+ ); +} diff --git a/src/components/workbench/modules/electronics/simulateElectronics.ts b/src/components/workbench/modules/electronics/simulateElectronics.ts new file mode 100644 index 0000000..6a3d6e0 --- /dev/null +++ b/src/components/workbench/modules/electronics/simulateElectronics.ts @@ -0,0 +1,361 @@ +import { + ElectronicCircuitState, + ElectronicComponent, + SimulationResult, +} from '@/types/electronics'; + +/** + * Simplified DC circuit simulator using node-voltage analysis. + * + * Supports: voltage sources, resistors, ground, switches, LEDs, NMOS/PMOS (ideal). + * MOSFETs are modeled as ideal switches: + * NMOS: ON (low Rds) when Vgs > threshold (2V), OFF (high Rds) otherwise + * PMOS: ON when Vgs < -threshold (-2V), OFF otherwise + * + * Uses iterative approach for nonlinear elements (transistors): + * 1. Assume all transistors OFF + * 2. Solve linear circuit + * 3. Check transistor states, update if changed + * 4. Repeat until stable (max 10 iterations) + */ + +const VTH = 2; // MOSFET threshold voltage +const R_ON = 1; // MOSFET ON resistance (Ω) +const R_OFF = 1e9; // MOSFET OFF resistance (Ω) +const R_LED = 100; // LED effective resistance when forward biased +const V_LED = 1.8; // LED forward voltage drop + +export function simulateElectronics(circuit: ElectronicCircuitState): SimulationResult { + const { components, wires } = circuit; + + if (components.length === 0) { + return { success: false, error: 'No hay componentes', nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() }; + } + + // Build net list: group connected terminals into nodes + const terminalToNode = new Map(); + let nodeCounter = 0; + + function getTerminalId(compId: string, terminal: string): string { + return `${compId}:${terminal}`; + } + + function findNode(termId: string): string { + let node = terminalToNode.get(termId); + if (!node) { + node = `n${nodeCounter++}`; + terminalToNode.set(termId, node); + } + return node; + } + + function mergeNodes(a: string, b: string) { + const nodeA = findNode(a); + const nodeB = findNode(b); + if (nodeA === nodeB) return; + // Replace all nodeB references with nodeA + for (const [key, val] of terminalToNode.entries()) { + if (val === nodeB) terminalToNode.set(key, nodeA); + } + } + + // Initialize all component terminals + for (const comp of components) { + const terminals = getComponentTerminals(comp.type); + for (const t of terminals) { + findNode(getTerminalId(comp.id, t)); + } + } + + // Process wires to merge nodes + for (const wire of wires) { + mergeNodes(wire.from, wire.to); + } + + // Find ground node + let groundNode: string | null = null; + for (const comp of components) { + if (comp.type === 'ground') { + groundNode = findNode(getTerminalId(comp.id, 'gnd')); + break; + } + } + + if (!groundNode) { + return { success: false, error: 'Necesitas un nodo de tierra (GND)', nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() }; + } + + // Get unique node names (excluding ground) + const allNodes = [...new Set(terminalToNode.values())]; + const nodeList = allNodes.filter((n) => n !== groundNode); + const nodeIndex = new Map(); + nodeList.forEach((n, i) => nodeIndex.set(n, i)); + + const N = nodeList.length; + if (N === 0) { + return { success: true, nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() }; + } + + // Count voltage sources for MNA + const voltageSources = components.filter((c) => c.type === 'voltage-source'); + const M = voltageSources.length; + const size = N + M; + + // Transistor states (start all OFF) + const mosfetState = new Map(); + for (const comp of components) { + if (comp.type === 'nmos' || comp.type === 'pmos') { + mosfetState.set(comp.id, false); + } + } + + let voltages: Float64Array = new Float64Array(size); + let converged = false; + + for (let iter = 0; iter < 10; iter++) { + // Build MNA matrix: [G B; C D] * [v; i] = [I; E] + const A = Array.from({ length: size }, () => new Float64Array(size)); + const b = new Float64Array(size); + + function nodeIdx(termId: string): number { + const node = terminalToNode.get(termId); + if (!node || node === groundNode) return -1; + return nodeIndex.get(node) ?? -1; + } + + // Stamp resistor: G(i,i) += 1/R, G(j,j) += 1/R, G(i,j) -= 1/R, G(j,i) -= 1/R + function stampResistor(termA: string, termB: string, R: number) { + const i = nodeIdx(termA); + const j = nodeIdx(termB); + const g = 1 / R; + if (i >= 0) A[i][i] += g; + if (j >= 0) A[j][j] += g; + if (i >= 0 && j >= 0) { A[i][j] -= g; A[j][i] -= g; } + } + + // Stamp voltage source + function stampVoltageSource(posTerminal: string, negTerminal: string, voltage: number, vsIdx: number) { + const i = nodeIdx(posTerminal); + const j = nodeIdx(negTerminal); + const k = N + vsIdx; + if (i >= 0) { A[i][k] += 1; A[k][i] += 1; } + if (j >= 0) { A[j][k] -= 1; A[k][j] -= 1; } + b[k] = voltage; + } + + // Process each component + let vsCount = 0; + for (const comp of components) { + switch (comp.type) { + case 'resistor': { + const R = comp.value ?? 1000; + stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), R); + break; + } + case 'voltage-source': { + const V = comp.value ?? 5; + stampVoltageSource( + getTerminalId(comp.id, 'pos'), + getTerminalId(comp.id, 'neg'), + V, vsCount++ + ); + break; + } + case 'capacitor': { + // In DC steady state, capacitor = open circuit (very high R) + // But we model it so voltage across it can be measured + stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), R_OFF); + break; + } + case 'led': { + // Simplified: resistor + voltage drop (model as resistor for now) + stampResistor(getTerminalId(comp.id, 'anode'), getTerminalId(comp.id, 'cathode'), R_LED); + break; + } + case 'switch': { + // Switches modeled as low resistance when "on" (value=1), high when "off" (value=0) + const isOn = (comp.value ?? 0) > 0; + stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), isOn ? 0.01 : R_OFF); + break; + } + case 'nmos': { + const isOn = mosfetState.get(comp.id) ?? false; + const R = isOn ? R_ON : R_OFF; + stampResistor(getTerminalId(comp.id, 'drain'), getTerminalId(comp.id, 'source'), R); + break; + } + case 'pmos': { + const isOn = mosfetState.get(comp.id) ?? false; + const R = isOn ? R_ON : R_OFF; + stampResistor(getTerminalId(comp.id, 'drain'), getTerminalId(comp.id, 'source'), R); + break; + } + case 'voltmeter': { + // Voltmeter = very high resistance (doesn't affect circuit) + stampResistor(getTerminalId(comp.id, 'pos'), getTerminalId(comp.id, 'neg'), 1e9); + break; + } + case 'ammeter': { + // Ammeter = very low resistance (passes all current) + stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), 0.001); + break; + } + case 'ground': + break; + } + } + + // Solve A * x = b using Gaussian elimination + const solved = solveLinearSystem(A, b, size); + if (!solved) { + return { success: false, error: 'El circuito no tiene solución (¿cortocircuito?)', nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() }; + } + voltages = solved; + + // Update MOSFET states based on solved voltages + let changed = false; + for (const comp of components) { + if (comp.type !== 'nmos' && comp.type !== 'pmos') continue; + + const gateNode = terminalToNode.get(getTerminalId(comp.id, 'gate')); + const sourceNode = terminalToNode.get(getTerminalId(comp.id, 'source')); + + const vGate = getNodeVoltage(gateNode, groundNode, nodeIndex, voltages); + const vSource = getNodeVoltage(sourceNode, groundNode, nodeIndex, voltages); + const vgs = vGate - vSource; + + let shouldBeOn = false; + if (comp.type === 'nmos') shouldBeOn = vgs > VTH; + if (comp.type === 'pmos') shouldBeOn = vgs < -VTH; + + const wasOn = mosfetState.get(comp.id) ?? false; + if (shouldBeOn !== wasOn) { + mosfetState.set(comp.id, shouldBeOn); + changed = true; + } + } + + if (!changed) { + converged = true; + break; + } + } + + if (!converged && mosfetState.size > 0) { + // Still usable, just warn + } + + // Build result maps + const nodeVoltages = new Map(); + for (const [termId, node] of terminalToNode.entries()) { + const v = getNodeVoltage(node, groundNode!, nodeIndex, voltages); + nodeVoltages.set(termId, Math.round(v * 1000) / 1000); + } + + const branchCurrents = new Map(); + // Calculate currents through resistors + for (const comp of components) { + if (comp.type === 'resistor') { + const va = nodeVoltages.get(getTerminalId(comp.id, 'a')) ?? 0; + const vb = nodeVoltages.get(getTerminalId(comp.id, 'b')) ?? 0; + const R = comp.value ?? 1000; + branchCurrents.set(comp.id, Math.round(((va - vb) / R) * 10000) / 10000); + } + } + + // Compute meter readings + const meterReadings = new Map(); + for (const comp of components) { + if (comp.type === 'voltmeter') { + const vPos = nodeVoltages.get(getTerminalId(comp.id, 'pos')) ?? 0; + const vNeg = nodeVoltages.get(getTerminalId(comp.id, 'neg')) ?? 0; + const diff = Math.round((vPos - vNeg) * 1000) / 1000; + meterReadings.set(comp.id, { value: diff, unit: 'V' }); + } + if (comp.type === 'ammeter') { + const vA = nodeVoltages.get(getTerminalId(comp.id, 'a')) ?? 0; + const vB = nodeVoltages.get(getTerminalId(comp.id, 'b')) ?? 0; + const current = Math.round(((vA - vB) / 0.001) * 10000) / 10000; + meterReadings.set(comp.id, { value: Math.abs(current) * 1000, unit: 'mA' }); + } + } + + return { success: true, nodeVoltages, branchCurrents, meterReadings }; +} + +function getNodeVoltage( + node: string | undefined, + groundNode: string, + nodeIndex: Map, + voltages: Float64Array +): number { + if (!node || node === groundNode) return 0; + const idx = nodeIndex.get(node); + if (idx === undefined) return 0; + return voltages[idx]; +} + +function getComponentTerminals(type: string): string[] { + switch (type) { + case 'voltage-source': return ['pos', 'neg']; + case 'resistor': return ['a', 'b']; + case 'capacitor': return ['a', 'b']; + case 'led': return ['anode', 'cathode']; + case 'switch': return ['a', 'b']; + case 'ground': return ['gnd']; + case 'nmos': return ['gate', 'drain', 'source']; + case 'pmos': return ['gate', 'drain', 'source']; + case 'voltmeter': return ['pos', 'neg']; + case 'ammeter': return ['a', 'b']; + default: return []; + } +} + +/** Gaussian elimination with partial pivoting */ +function solveLinearSystem(A: Float64Array[], b: Float64Array, n: number): Float64Array | null { + // Augmented matrix + const aug = A.map((row, i) => { + const r = new Float64Array(n + 1); + r.set(row); + r[n] = b[i]; + return r; + }); + + for (let col = 0; col < n; col++) { + // Pivot + let maxRow = col; + let maxVal = Math.abs(aug[col][col]); + for (let row = col + 1; row < n; row++) { + if (Math.abs(aug[row][col]) > maxVal) { + maxVal = Math.abs(aug[row][col]); + maxRow = row; + } + } + if (maxVal < 1e-12) continue; // singular, skip + [aug[col], aug[maxRow]] = [aug[maxRow], aug[col]]; + + // Eliminate + for (let row = col + 1; row < n; row++) { + const factor = aug[row][col] / aug[col][col]; + for (let j = col; j <= n; j++) { + aug[row][j] -= factor * aug[col][j]; + } + } + } + + // Back substitution + const x = new Float64Array(n); + for (let row = n - 1; row >= 0; row--) { + if (Math.abs(aug[row][row]) < 1e-12) { + x[row] = 0; + continue; + } + let sum = aug[row][n]; + for (let j = row + 1; j < n; j++) { + sum -= aug[row][j] * x[j]; + } + x[row] = sum / aug[row][row]; + } + + return x; +} diff --git a/src/data/challenges/circuits.ts b/src/data/challenges/circuits.ts new file mode 100644 index 0000000..1137e26 --- /dev/null +++ b/src/data/challenges/circuits.ts @@ -0,0 +1,257 @@ +import { Challenge } from '@/types/challenge'; +import { CircuitBuilderContent, TruthTableRow } from '@/types/circuit'; + +function circuitChallenge( + id: string, + nodeId: string, + title: string, + description: string, + availableGates: CircuitBuilderContent['availableGates'], + inputLabels: string[], + outputLabels: string[], + truthTable: TruthTableRow[], + difficulty: 1 | 2 | 3 | 4 | 5, + hints: string[] = [], + explanation?: string, + maxGates?: number +): Challenge { + return { + id: `${nodeId}/${id}`, + nodeId, + title, + description, + difficulty, + type: 'circuit-builder', + hints, + xpReward: difficulty * 25, + explanation, + content: { + type: 'circuit-builder', + availableGates, + inputLabels, + outputLabels, + truthTable, + maxGates, + }, + }; +} + +// === LOGIC GATES NODE === + +export const logicGateChallenges: Challenge[] = [ + circuitChallenge( + 'lg-01', 'electronics.logic-gates', + 'Puerta AND', + 'Construye un circuito donde la salida Y sea 1 solo cuando AMBAS entradas A y B sean 1.', + ['AND'], + ['A', 'B'], ['Y'], + [ + { inputs: [false, false], outputs: [false] }, + { inputs: [false, true], outputs: [false] }, + { inputs: [true, false], outputs: [false] }, + { inputs: [true, true], outputs: [true] }, + ], + 2, + ['Necesitas solo una puerta AND'], + 'La puerta AND produce 1 solo cuando TODAS sus entradas son 1.\n\nTabla de verdad:\n A | B | Y\n 0 | 0 | 0\n 0 | 1 | 0\n 1 | 0 | 0\n 1 | 1 | 1\n\nAnalogia: piensa en dos interruptores en serie. La luz solo se enciende si AMBOS están encendidos.' + ), + + circuitChallenge( + 'lg-02', 'electronics.logic-gates', + 'Puerta OR', + 'Construye un circuito donde la salida Y sea 1 cuando AL MENOS UNA entrada sea 1.', + ['OR'], + ['A', 'B'], ['Y'], + [ + { inputs: [false, false], outputs: [false] }, + { inputs: [false, true], outputs: [true] }, + { inputs: [true, false], outputs: [true] }, + { inputs: [true, true], outputs: [true] }, + ], + 2, + ['Necesitas solo una puerta OR'], + 'La puerta OR produce 1 cuando AL MENOS UNA entrada es 1.\n\nTabla de verdad:\n A | B | Y\n 0 | 0 | 0\n 0 | 1 | 1\n 1 | 0 | 1\n 1 | 1 | 1\n\nAnalogia: dos interruptores en paralelo. La luz se enciende si CUALQUIERA está encendido.' + ), + + circuitChallenge( + 'lg-03', 'electronics.logic-gates', + 'Puerta NOT', + 'Construye un circuito donde la salida Y sea lo opuesto a la entrada A.', + ['NOT'], + ['A'], ['Y'], + [ + { inputs: [false], outputs: [true] }, + { inputs: [true], outputs: [false] }, + ], + 2, + ['La puerta NOT invierte la señal'], + 'La puerta NOT (inversora) invierte la señal: si entra 1, sale 0 y viceversa.\n\n A | Y\n 0 | 1\n 1 | 0\n\nEs la puerta más simple. Solo tiene una entrada y una salida.' + ), + + circuitChallenge( + 'lg-04', 'electronics.logic-gates', + 'Puerta NAND', + 'Construye un circuito con una puerta NAND. La salida Y es 0 solo cuando A y B son ambas 1.', + ['NAND'], + ['A', 'B'], ['Y'], + [ + { inputs: [false, false], outputs: [true] }, + { inputs: [false, true], outputs: [true] }, + { inputs: [true, false], outputs: [true] }, + { inputs: [true, true], outputs: [false] }, + ], + 2, + ['NAND = NOT AND. Es el opuesto de AND.'], + 'NAND significa "NOT AND" — es una puerta AND seguida de un NOT.\n\nTabla de verdad (opuesta a AND):\n A | B | Y\n 0 | 0 | 1\n 0 | 1 | 1\n 1 | 0 | 1\n 1 | 1 | 0\n\nDato clave: la NAND es una "puerta universal" — con solo puertas NAND puedes construir CUALQUIER otra puerta lógica.' + ), + + circuitChallenge( + 'lg-05', 'electronics.logic-gates', + 'Puerta XOR', + 'Construye un circuito donde Y sea 1 cuando A y B sean DIFERENTES.', + ['XOR'], + ['A', 'B'], ['Y'], + [ + { inputs: [false, false], outputs: [false] }, + { inputs: [false, true], outputs: [true] }, + { inputs: [true, false], outputs: [true] }, + { inputs: [true, true], outputs: [false] }, + ], + 2, + ['XOR = "exclusive OR". Es 1 cuando las entradas son distintas.'], + 'XOR (OR exclusivo) produce 1 cuando las entradas son DIFERENTES.\n\n A | B | Y\n 0 | 0 | 0\n 0 | 1 | 1\n 1 | 0 | 1\n 1 | 1 | 0\n\nA diferencia de OR, XOR da 0 cuando ambas son 1. Es fundamental para la aritmética binaria (suma sin acarreo).' + ), + + circuitChallenge( + 'lg-06', 'electronics.logic-gates', + 'NOT con NAND', + 'Construye un inversor (NOT) usando SOLO puertas NAND.', + ['NAND'], + ['A'], ['Y'], + [ + { inputs: [false], outputs: [true] }, + { inputs: [true], outputs: [false] }, + ], + 3, + ['Conecta ambas entradas de la NAND a la misma señal'], + 'Si conectas ambas entradas de una NAND a la misma señal:\n\nNAND(A, A) = NOT(A AND A) = NOT(A)\n\nEsto demuestra que NAND puede funcionar como NOT. Es el primer paso para demostrar que NAND es universal.' + ), + + circuitChallenge( + 'lg-07', 'electronics.logic-gates', + 'AND con NAND', + 'Construye una puerta AND usando SOLO puertas NAND.', + ['NAND'], + ['A', 'B'], ['Y'], + [ + { inputs: [false, false], outputs: [false] }, + { inputs: [false, true], outputs: [false] }, + { inputs: [true, false], outputs: [false] }, + { inputs: [true, true], outputs: [true] }, + ], + 3, + ['Primero haz NAND(A,B), luego invierte el resultado con otra NAND'], + 'NAND(A,B) da NOT(AND(A,B)). Si invertimos eso:\n\nNOT(NAND(A,B)) = NOT(NOT(AND(A,B))) = AND(A,B)\n\nNecesitas 2 puertas NAND:\n1. NAND(A, B) → resultado intermedio\n2. NAND(resultado, resultado) → AND(A, B)', + 3 + ), + + circuitChallenge( + 'lg-08', 'electronics.logic-gates', + 'OR con NAND', + 'Construye una puerta OR usando SOLO puertas NAND.', + ['NAND'], + ['A', 'B'], ['Y'], + [ + { inputs: [false, false], outputs: [false] }, + { inputs: [false, true], outputs: [true] }, + { inputs: [true, false], outputs: [true] }, + { inputs: [true, true], outputs: [true] }, + ], + 3, + ['Primero invierte cada entrada con NAND, luego NAND los resultados'], + 'Por la ley de De Morgan: OR(A,B) = NOT(AND(NOT(A), NOT(B)))\n\nCon NANDs:\n1. NAND(A,A) = NOT(A)\n2. NAND(B,B) = NOT(B)\n3. NAND(NOT(A), NOT(B)) = NOT(AND(NOT(A),NOT(B))) = OR(A,B)\n\nNecesitas 3 puertas NAND.', + 3 + ), +]; + +// === COMBINATIONAL CIRCUITS NODE === + +export const combinationalChallenges: Challenge[] = [ + circuitChallenge( + 'cc-01', 'electronics.combinational', + 'Medio Sumador (Half Adder)', + 'Construye un circuito que sume dos bits A y B. Produce dos salidas: Sum (suma) y Carry (acarreo).', + ['AND', 'XOR'], + ['A', 'B'], ['Sum', 'Carry'], + [ + { inputs: [false, false], outputs: [false, false] }, + { inputs: [false, true], outputs: [true, false] }, + { inputs: [true, false], outputs: [true, false] }, + { inputs: [true, true], outputs: [false, true] }, + ], + 3, + ['Sum = A XOR B, Carry = A AND B'], + 'El medio sumador (half adder) suma dos bits:\n\n A | B | Sum | Carry\n 0 | 0 | 0 | 0\n 0 | 1 | 1 | 0\n 1 | 0 | 1 | 0\n 1 | 1 | 0 | 1\n\n• Sum (suma): es 1 cuando A y B son diferentes → XOR\n• Carry (acarreo): es 1 cuando ambos son 1 → AND\n\nEste circuito es la base de toda la aritmética en computadoras.' + ), + + circuitChallenge( + 'cc-02', 'electronics.combinational', + 'Multiplexor 2:1', + 'Construye un MUX: cuando Sel=0, Y=D0. Cuando Sel=1, Y=D1.', + ['AND', 'OR', 'NOT'], + ['D0', 'D1', 'Sel'], ['Y'], + [ + { inputs: [false, false, false], outputs: [false] }, + { inputs: [false, false, true], outputs: [false] }, + { inputs: [false, true, false], outputs: [false] }, + { inputs: [false, true, true], outputs: [true] }, + { inputs: [true, false, false], outputs: [true] }, + { inputs: [true, false, true], outputs: [false] }, + { inputs: [true, true, false], outputs: [true] }, + { inputs: [true, true, true], outputs: [true] }, + ], + 3, + ['Y = (D0 AND NOT Sel) OR (D1 AND Sel)'], + 'Un multiplexor (MUX) selecciona una de varias entradas según una señal de selección.\n\nMUX 2:1:\n• Si Sel=0 → Y = D0\n• Si Sel=1 → Y = D1\n\nFórmula: Y = (D0 · NOT(Sel)) + (D1 · Sel)\n\nNecesitas: 1 NOT, 2 AND, 1 OR' + ), + + circuitChallenge( + 'cc-03', 'electronics.combinational', + 'Detector de Paridad', + 'Construye un circuito que detecte si el número de 1s entre A y B es impar (paridad impar).', + ['XOR'], + ['A', 'B'], ['P'], + [ + { inputs: [false, false], outputs: [false] }, + { inputs: [false, true], outputs: [true] }, + { inputs: [true, false], outputs: [true] }, + { inputs: [true, true], outputs: [false] }, + ], + 3, + ['La paridad impar de 2 bits es simplemente XOR'], + 'La paridad es un método para detectar errores en transmisión de datos.\n\nParidad impar: P=1 si hay un número impar de 1s en la entrada.\n\nPara 2 bits, resulta que A XOR B ya lo calcula:\n 0 XOR 0 = 0 (cero 1s, par)\n 0 XOR 1 = 1 (un 1, impar)\n 1 XOR 0 = 1 (un 1, impar)\n 1 XOR 1 = 0 (dos 1s, par)' + ), + + circuitChallenge( + 'cc-04', 'electronics.combinational', + 'Half Adder con NAND', + 'Construye un medio sumador usando SOLO puertas NAND.', + ['NAND'], + ['A', 'B'], ['Sum', 'Carry'], + [ + { inputs: [false, false], outputs: [false, false] }, + { inputs: [false, true], outputs: [true, false] }, + { inputs: [true, false], outputs: [true, false] }, + { inputs: [true, true], outputs: [false, true] }, + ], + 4, + ['Carry = NOT(NAND(A,B)). Para Sum necesitas construir XOR con NANDs.'], + 'Desafío avanzado: construir un half adder completo solo con NANDs.\n\nRecuerda:\n• Carry = AND(A,B) = NAND(NAND(A,B), NAND(A,B))\n• Sum = XOR(A,B) — necesitas construir XOR con NANDs\n\nXOR con NANDs:\n t = NAND(A,B)\n Sum = NAND(NAND(A,t), NAND(B,t))\n\nTotal: 5 puertas NAND', + 5 + ), +]; + +export const allCircuitChallenges: Challenge[] = [ + ...logicGateChallenges, + ...combinationalChallenges, +]; diff --git a/src/data/challenges/electronics.ts b/src/data/challenges/electronics.ts new file mode 100644 index 0000000..cdea82b --- /dev/null +++ b/src/data/challenges/electronics.ts @@ -0,0 +1,479 @@ +import { Challenge } from '@/types/challenge'; +import { ElectronicsContent } from '@/types/electronics'; + +function elecChallenge( + id: string, + nodeId: string, + title: string, + description: string, + content: ElectronicsContent, + difficulty: 1 | 2 | 3 | 4 | 5, + hints: string[] = [], + explanation?: string +): Challenge { + return { + id: `${nodeId}/${id}`, + nodeId, + title, + description, + difficulty, + type: 'electronics-lab', + hints, + xpReward: difficulty * 25, + explanation, + content, + }; +} + +function mathElecChallenge( + id: string, + nodeId: string, + title: string, + problem: string, + answer: number, + difficulty: 1 | 2 | 3 | 4 | 5, + hints: string[] = [], + tolerance = 0, + explanation?: string +): Challenge { + return { + id: `${nodeId}/${id}`, + nodeId, + title, + description: problem, + difficulty, + type: 'math-input', + hints, + xpReward: difficulty * 25, + explanation, + content: { + type: 'math-input', + problem, + answer: { type: 'numeric', value: answer, tolerance }, + }, + }; +} + +// ========================================================================= +// ELECTRICITY BASICS +// ========================================================================= +export const electricityBasicsChallenges: Challenge[] = [ + elecChallenge( + 'eb-01', 'electronics.basics', + 'Ley de Ohm', + 'Conecta una fuente de 5V a una resistencia de 1000Ω y a tierra. Usa el voltímetro para medir el voltaje en la fuente.', + { + type: 'electronics-lab', + availableComponents: ['voltage-source', 'resistor', 'ground', 'voltmeter', 'ammeter'], + probes: [ + { type: 'voltmeter', expected: 5, tolerance: 0.5, label: 'Voltaje en la fuente', unit: 'V' }, + ], + }, + 2, + ['Conecta: Fuente(+) → Resistencia → Tierra. Fuente(-) → Tierra.'], + 'La Ley de Ohm es la base de toda la electrónica:\n\nV = I × R\n\nDonde:\n• V = Voltaje (Voltios, V)\n• I = Corriente (Amperios, A)\n• R = Resistencia (Ohmios, Ω)\n\nSi tienes una fuente de 5V y una resistencia de 1000Ω:\nI = V/R = 5/1000 = 0.005A = 5mA\n\nInstrumentos de medida:\n• Voltímetro (V): mide voltaje ENTRE dos puntos. Se conecta en PARALELO.\n• Amperímetro (A): mide corriente QUE PASA por un punto. Se conecta en SERIE.\n\nPulsa "Simular" cuando tu circuito esté listo.' + ), + + mathElecChallenge( + 'eb-02', 'electronics.basics', + 'Calcula la corriente', + 'Una fuente de 9V alimenta una resistencia de 4500Ω. ¿Cuántos miliamperios (mA) circulan?', + 2, 2, + ['I = V/R = 9/4500. Convierte a mA multiplicando por 1000.'], + 0.1, + 'Ley de Ohm despejada para corriente:\n\nI = V / R\n\nEjemplo: con 12V y 3000Ω → I = 12/3000 = 0.004A = 4mA\n\nRecuerda: 1A = 1000mA' + ), + + elecChallenge( + 'eb-03', 'electronics.basics', + 'Divisor de Voltaje', + 'Crea un divisor de voltaje con dos resistencias de 1000Ω alimentado por 5V. El punto medio debe tener 2.5V.', + { + type: 'electronics-lab', + availableComponents: ['voltage-source', 'resistor', 'ground', 'voltmeter'], + probes: [ + { type: 'voltmeter', expected: 2.5, tolerance: 0.5, label: 'Voltaje en punto medio', unit: 'V' }, + ], + }, + 2, + ['Conecta: Fuente(+) → R1 → punto medio → R2 → GND'], + 'Un divisor de voltaje usa dos resistencias en serie:\n\n Vout = Vin × R2 / (R1 + R2)\n\nCon R1 = R2 = 1kΩ y Vin = 5V:\n Vout = 5 × 1000 / (1000 + 1000) = 2.5V\n\nEstructura:\n 5V → [R1] → punto medio (2.5V) → [R2] → GND' + ), + + mathElecChallenge( + 'eb-04', 'electronics.basics', + 'Calcula la resistencia', + 'Necesitas que circulen exactamente 20mA con una fuente de 12V. ¿Qué resistencia necesitas en ohmios?', + 600, 2, + ['R = V/I. Convierte 20mA a amperios primero: 0.020A'], + 0, + 'Ley de Ohm despejada para resistencia:\n\nR = V / I\n\nEjemplo: con 10V y 25mA → R = 10/0.025 = 400Ω' + ), +]; + +// ========================================================================= +// SERIES & PARALLEL +// ========================================================================= +export const seriesParallelChallenges: Challenge[] = [ + mathElecChallenge( + 'sp-01', 'electronics.series-parallel', + 'Resistencias en Serie', + 'Tres resistencias de 100Ω, 220Ω y 330Ω están en serie. ¿Cuál es la resistencia total en ohmios?', + 650, 2, + ['En serie se suman: Rtotal = R1 + R2 + R3'], + 0, + 'Resistencias en SERIE — se suman:\n\nRtotal = R1 + R2 + R3 + ...\n\nLa corriente es la MISMA en cada resistencia.\nEl voltaje se REPARTE entre ellas.\n\nEjemplo: 150Ω + 270Ω = 420Ω' + ), + + mathElecChallenge( + 'sp-02', 'electronics.series-parallel', + 'Resistencias en Paralelo', + 'Dos resistencias de 1000Ω están en paralelo. ¿Cuál es la resistencia total?', + 500, 2, + ['1/Rtotal = 1/R1 + 1/R2. Con dos iguales: Rtotal = R/2'], + 0, + 'Resistencias en PARALELO — inverso de la suma de inversos:\n\n1/Rtotal = 1/R1 + 1/R2 + ...\n\nCon dos resistencias iguales: Rtotal = R/2\n\nEl voltaje es el MISMO en cada resistencia.\nLa corriente se REPARTE.\n\nEjemplo: dos de 470Ω → Rtotal = 470/2 = 235Ω' + ), + + mathElecChallenge( + 'sp-03', 'electronics.series-parallel', + 'Paralelo desigual', + 'Dos resistencias de 300Ω y 600Ω en paralelo. ¿Cuál es la resistencia total?', + 200, 3, + ['1/Rtotal = 1/300 + 1/600 = 3/600. Rtotal = 600/3'], + 0, + 'Con resistencias distintas:\n\n1/Rtotal = 1/R1 + 1/R2\n\nTruco para dos resistencias: Rtotal = (R1 × R2) / (R1 + R2)\n\nEjemplo: 200Ω ∥ 400Ω = (200×400)/(200+400) = 80000/600 ≈ 133Ω' + ), + + elecChallenge( + 'sp-04', 'electronics.series-parallel', + 'Divisor 3:1', + 'Crea un divisor de voltaje que produzca 1.25V a partir de 5V. Usa una resistencia de 3000Ω y otra de 1000Ω.', + { + type: 'electronics-lab', + availableComponents: ['voltage-source', 'resistor', 'ground', 'voltmeter'], + probes: [ + { type: 'voltmeter', expected: 1.25, tolerance: 0.3, label: 'Voltaje de salida', unit: 'V' }, + ], + }, + 3, + ['Vout = 5 × R2/(R1+R2). Pon R1=3kΩ arriba y R2=1kΩ abajo.'], + 'Divisor con proporción 3:1:\n\nVout = Vin × R2 / (R1 + R2)\nVout = 5 × 1000 / (3000 + 1000) = 5 × 1/4 = 1.25V\n\nR1 (la de arriba) "absorbe" más voltaje porque es más grande.' + ), +]; + +// ========================================================================= +// CAPACITORS +// ========================================================================= +export const capacitorChallenges: Challenge[] = [ + mathElecChallenge( + 'cap-01', 'electronics.capacitors', + 'Carga del condensador', + 'Un condensador de 100µF se carga a través de una resistencia de 10kΩ. ¿Cuál es la constante de tiempo τ (tau) en segundos?', + 1, 2, + ['τ = R × C. Cuidado con las unidades: µF = 10⁻⁶F, kΩ = 10³Ω'], + 0.01, + 'Un condensador almacena energía en un campo eléctrico.\n\nLa constante de tiempo τ (tau) determina la velocidad de carga:\n\nτ = R × C\n\nEjemplo: con 5kΩ y 200µF → τ = 5000 × 0.0002 = 1s\n\n• Después de 1τ, el condensador está al 63% de carga\n• Después de 5τ, prácticamente al 100%' + ), + + mathElecChallenge( + 'cap-02', 'electronics.capacitors', + 'Condensadores en paralelo', + 'Tres condensadores de 10µF, 22µF y 47µF en paralelo. ¿Cuál es la capacitancia total en µF?', + 79, 2, + ['En paralelo se suman: Ctotal = C1 + C2 + C3'], + 0, + 'Los condensadores en paralelo se SUMAN (al revés que las resistencias):\n\nCtotal = C1 + C2 + C3 + ...\n\nEjemplo: 15µF + 33µF = 48µF\n\nEn paralelo: más capacitancia = más almacenamiento.\nEn serie: 1/Ctotal = 1/C1 + 1/C2 + ... (se reduce, opuesto a resistencias).' + ), + + mathElecChallenge( + 'cap-03', 'electronics.capacitors', + 'Frecuencia de corte RC', + 'Un filtro RC tiene R=1kΩ y C=1µF. ¿Cuál es la frecuencia de corte en Hz? (redondeada)', + 159, 3, + ['f = 1 / (2π × R × C). R en Ω, C en F.'], + 2, + 'Un circuito RC forma un filtro:\n\n• Filtro paso-bajo: deja pasar frecuencias bajas\n• Filtro paso-alto: deja pasar frecuencias altas\n\nLa frecuencia de corte (-3dB):\n\nfc = 1 / (2π × R × C)\n\nEjemplo: con R=2.2kΩ y C=0.1µF → fc = 1/(2π × 2200 × 0.0000001) ≈ 723Hz\n\nFundamental en audio (filtros de sintetizadores) y comunicaciones.' + ), + + elecChallenge( + 'cap-04', 'electronics.capacitors', + 'Condensador bloquea DC', + 'Conecta: Fuente 5V → Resistencia 1kΩ → Condensador → Resistencia 1kΩ → GND. Mide el voltaje con el voltímetro en la segunda resistencia (después del condensador). ¿Cuánto marca?', + { + type: 'electronics-lab', + availableComponents: ['voltage-source', 'resistor', 'capacitor', 'ground', 'voltmeter'], + probes: [ + { type: 'voltmeter', expected: 0, tolerance: 0.1, label: 'Voltaje después del condensador', unit: 'V' }, + ], + }, + 2, + ['En DC, el condensador no deja pasar corriente. La segunda resistencia ve 0V.'], + 'En corriente continua (DC), un condensador cargado actúa como un CIRCUITO ABIERTO.\n\nNo deja pasar corriente DC una vez cargado. Por eso:\n• Todo el voltaje cae entre la fuente y el condensador\n• Después del condensador: 0V, 0A\n\nEsto es útil para:\n• Bloquear DC y dejar pasar AC (condensador de acoplo)\n• Filtrar ruido de alta frecuencia (condensador de bypass)\n\nConecta el voltímetro en paralelo con la segunda resistencia para comprobarlo.' + ), + + elecChallenge( + 'cap-05', 'electronics.capacitors', + 'Condensador de bypass', + 'Añade un condensador de 100µF en paralelo con una resistencia de carga de 1kΩ alimentada por 5V. Mide el voltaje con el voltímetro en la resistencia. Debe ser 5V (el condensador estabiliza el voltaje).', + { + type: 'electronics-lab', + availableComponents: ['voltage-source', 'resistor', 'capacitor', 'ground', 'voltmeter'], + probes: [ + { type: 'voltmeter', expected: 5, tolerance: 0.5, label: 'Voltaje en la carga', unit: 'V' }, + ], + }, + 3, + ['Fuente(+) → R → punto de carga → GND. Condensador en paralelo: un terminal al punto de carga, otro a GND.'], + 'Un condensador de bypass se coloca en PARALELO con la carga.\n\nEn DC actúa como circuito abierto, así que no cambia el voltaje estático.\nPero en la práctica absorbe picos y fluctuaciones de voltaje.\n\nCircuito:\n 5V → [R] → punto ─┬─ [Carga R] → GND\n └─ [C bypass] → GND\n\nEl voltímetro en el punto de carga debe leer el voltaje completo de la fuente (menos la caída en R de alimentación).' + ), +]; + +// ========================================================================= +// DIODES & LEDs +// ========================================================================= +export const diodeChallenges: Challenge[] = [ + elecChallenge( + 'diode-01', 'electronics.diodes', + 'LED con Resistencia', + 'Conecta un LED con una resistencia de protección a una fuente de 5V.', + { + type: 'electronics-lab', + availableComponents: ['voltage-source', 'resistor', 'led', 'ground', 'voltmeter', 'ammeter'], + probes: [ + { type: 'voltmeter', expected: 5, tolerance: 0.5, label: 'Voltaje de la fuente', unit: 'V' }, + ], + }, + 2, + ['Conecta: Fuente(+) → Resistencia → LED(ánodo→cátodo) → GND'], + 'Un LED emite luz cuando la corriente fluye del ánodo (+) al cátodo (-).\n\nNUNCA conectes un LED directamente a la fuente sin resistencia — se quema.\n\nCircuito típico:\n Fuente(+) → [Resistencia] → LED(ánodo→cátodo) → GND\n\nLa resistencia limita la corriente:\n R = (Vfuente - Vled) / I\n R = (5 - 1.8) / 0.020 = 160Ω → usa 220Ω' + ), + + mathElecChallenge( + 'diode-02', 'electronics.diodes', + 'Resistencia para LED', + 'Tienes una fuente de 9V, un LED con caída de 2V y quieres 15mA. ¿Qué resistencia necesitas? (en Ω, redondeada)', + 467, 2, + ['R = (Vfuente - Vled) / I = (9-2) / 0.015'], + 5, + 'Para calcular la resistencia de protección de un LED:\n\nR = (Vfuente - Vled) / I_deseada\n\nEjemplo: con 5V, LED de 1.8V, y 20mA → R = (5-1.8)/0.020 = 160Ω → usa 180Ω\n\nEn la práctica se elige el valor comercial más cercano por arriba.' + ), + + { + id: 'electronics.diodes/diode-03', + nodeId: 'electronics.diodes', + title: 'Polarización del diodo', + description: '¿En qué dirección debe fluir la corriente para que un diodo conduzca?', + difficulty: 2, + type: 'multiple-choice', + hints: ['El diodo conduce del ánodo al cátodo'], + xpReward: 50, + explanation: 'Un diodo es como una válvula: solo deja pasar corriente en UN sentido.\n\n• Polarización directa: corriente fluye del ÁNODO (+) al CÁTODO (-) → CONDUCE\n• Polarización inversa: corriente intenta ir del cátodo al ánodo → BLOQUEADO\n\nEl diodo necesita una tensión mínima (Vf ≈ 0.7V para silicio, ≈ 1.8V para LED) para empezar a conducir.', + content: { + type: 'multiple-choice', + question: '¿En qué sentido conduce un diodo?', + options: [ + 'Del ánodo (+) al cátodo (-)', + 'Del cátodo (-) al ánodo (+)', + 'En ambas direcciones', + 'No conduce nunca', + ], + correctIndex: 0, + }, + }, + + mathElecChallenge( + 'diode-04', 'electronics.diodes', + 'Diodos en serie', + 'Tres LEDs con caída de 2V cada uno en serie, alimentados por 12V. ¿Qué resistencia necesitas para 10mA?', + 600, 3, + ['Vtotal_leds = 3 × 2V = 6V. R = (12-6) / 0.01'], + 5, + 'Cuando pones diodos/LEDs en serie, las caídas de voltaje se SUMAN:\n\nVtotal_leds = n × Vled\nVresistencia = Vfuente - Vtotal_leds\nR = Vresistencia / I\n\nEjemplo: 2 LEDs de 1.8V con 5V y 20mA → R = (5-3.6)/0.02 = 70Ω' + ), +]; + +// ========================================================================= +// TRANSISTORS (expanded) +// ========================================================================= +export const transistorChallenges: Challenge[] = [ + elecChallenge( + 'tr-01', 'electronics.transistors', + 'NMOS como Interruptor', + 'Usa un transistor NMOS para controlar un LED. Cuando el gate tiene voltaje alto (5V), el LED debe encenderse.', + { + type: 'electronics-lab', + availableComponents: ['voltage-source', 'resistor', 'nmos', 'ground', 'voltmeter', 'ammeter'], + probes: [ + { type: 'voltmeter', expected: 0, tolerance: 1, label: 'Voltaje en drain del NMOS (bajo = ON)', unit: 'V' }, + ], + }, + 3, + ['Conecta: 5V → Resistencia → Drain del NMOS. Source del NMOS → GND. Gate → 5V.'], + 'Un transistor MOSFET funciona como un interruptor controlado por voltaje.\n\nNMOS (N-channel MOSFET):\n• Gate = señal de control\n• Drain = entrada de corriente\n• Source = salida (conectar a GND)\n\nCuando Vgate > umbral (~2V) → ENCIENDE (conduce)\nCuando Vgate < umbral → APAGA\n\nCircuito:\n 5V → [R] → Drain ── Source → GND\n ↑\n Gate = 5V (ON) o 0V (OFF)' + ), + + elecChallenge( + 'tr-02', 'electronics.transistors', + 'PMOS como Interruptor', + 'El PMOS se enciende cuando el gate tiene voltaje BAJO. Conecta un PMOS para que conduzca.', + { + type: 'electronics-lab', + availableComponents: ['voltage-source', 'resistor', 'pmos', 'ground', 'voltmeter'], + probes: [ + { type: 'voltmeter', expected: 0, tolerance: 1, label: 'Voltaje en drain del PMOS', unit: 'V' }, + ], + }, + 3, + ['PMOS: Source a VDD (5V), Gate a GND (0V), Drain a la carga+GND.'], + 'PMOS (P-channel MOSFET) — complementario del NMOS:\n\n• Se ENCIENDE cuando Vgate < Vsource - umbral\n• Se APAGA cuando gate está alto\n\n| | NMOS | PMOS |\n| Gate alto | ON | OFF |\n| Gate bajo | OFF | ON |\n\nEl PMOS conecta Source a VDD y Drain a la carga.\nJuntos forman la tecnología CMOS.' + ), + + elecChallenge( + 'tr-03', 'electronics.transistors', + 'Inversor CMOS (NOT)', + 'Construye un inversor (NOT) con un PMOS y un NMOS. Entrada alta (5V) → salida baja (0V).', + { + type: 'electronics-lab', + availableComponents: ['voltage-source', 'nmos', 'pmos', 'ground', 'voltmeter'], + probes: [ + { type: 'voltmeter', expected: 0, tolerance: 1, label: 'Voltaje de salida (debe ser ~0V con entrada alta)', unit: 'V' }, + ], + }, + 4, + ['PMOS: Source→VDD, Gate→entrada. NMOS: Source→GND, Gate→entrada. Drains juntos = salida.'], + 'El inversor CMOS es el circuito fundamental de TODA la electrónica digital moderna.\n\nEstructura:\n VDD (5V)\n |\n [PMOS] Source→VDD, Gate→Entrada\n |\n Salida ← Drains conectados\n |\n [NMOS] Source→GND, Gate→Entrada\n |\n GND\n\nEntrada HIGH → NMOS ON, PMOS OFF → Salida = GND = LOW\nEntrada LOW → NMOS OFF, PMOS ON → Salida = VDD = HIGH\n\n¡Esto es una puerta NOT hecha con transistores!' + ), + + { + id: 'electronics.transistors/tr-04', + nodeId: 'electronics.transistors', + title: 'CMOS vs NMOS puro', + description: '¿Por qué CMOS (PMOS + NMOS) es mejor que usar solo NMOS con resistencia de pull-up?', + difficulty: 3, + type: 'multiple-choice', + hints: ['Piensa en el consumo de energía cuando el circuito está en reposo'], + xpReward: 75, + explanation: 'Con NMOS + resistencia de pull-up:\n• Cuando NMOS ON: corriente fluye de VDD → R → NMOS → GND\n• Consume energía CONSTANTEMENTE en estado LOW\n\nCon CMOS (PMOS + NMOS):\n• Solo uno conduce a la vez\n• Nunca hay camino directo de VDD a GND\n• Consumo casi CERO en reposo\n\nPor eso todos los chips modernos usan CMOS — millones de transistores sin derretirse.', + content: { + type: 'multiple-choice', + question: '¿Cuál es la principal ventaja de CMOS sobre NMOS puro?', + options: [ + 'Es más rápido', + 'Consume mucha menos energía en reposo', + 'Usa menos transistores', + 'Es más fácil de fabricar', + ], + correctIndex: 1, + }, + }, +]; + +// ========================================================================= +// OP-AMP +// ========================================================================= +export const opampChallenges: Challenge[] = [ + { + id: 'electronics.opamp/opamp-01', + nodeId: 'electronics.opamp', + title: 'El Op-Amp ideal', + description: '¿Cuáles son las dos reglas doradas del amplificador operacional ideal?', + difficulty: 3, + type: 'multiple-choice', + hints: ['Piensa en corriente de entrada e igualdad de voltajes'], + xpReward: 75, + explanation: 'El amplificador operacional (op-amp) es un bloque analógico versátil.\n\nTiene 3 terminales: entrada no-inversora (+), entrada inversora (-), y salida.\n\nReglas doradas del op-amp ideal (con realimentación negativa):\n\n1. No entra corriente por las entradas (impedancia infinita)\n2. El voltaje en (+) es IGUAL al voltaje en (-)\n\nCon estas dos reglas puedes analizar CUALQUIER circuito con op-amps.\n\nEl op-amp multiplica la diferencia de voltaje entre sus entradas por una ganancia muy alta (100,000x o más).', + content: { + type: 'multiple-choice', + question: '¿Cuáles son las reglas doradas del op-amp ideal (con feedback negativo)?', + options: [ + 'Ganancia infinita y salida siempre 0V', + 'No entra corriente por las entradas Y V(+) = V(-)', + 'Consume 0W y tiene delay 0', + 'Solo amplifica señales DC', + ], + correctIndex: 1, + }, + }, + + mathElecChallenge( + 'opamp-02', 'electronics.opamp', + 'Amplificador Inversor', + 'Un op-amp inversor tiene Rf=10kΩ y Rin=2kΩ. Si la entrada es 0.5V, ¿cuál es el voltaje de salida? (con signo)', + -2.5, 3, + ['Ganancia = -Rf/Rin = -10000/2000 = -5. Vout = Ganancia × Vin'], + 0.1, + 'El amplificador inversor:\n\nVin → [Rin] → nodo (-) del op-amp\n |\n [Rf] → Salida\n\nGanancia = -Rf / Rin\n\nEjemplo: con Rf=20kΩ, Rin=5kΩ → G = -20000/5000 = -4\nSi Vin=1V → Vout = -4 × 1 = -4V\n\nEl signo negativo indica que INVIERTE la señal.' + ), + + mathElecChallenge( + 'opamp-03', 'electronics.opamp', + 'Amplificador No Inversor', + 'Un op-amp no inversor tiene Rf=9kΩ y R1=1kΩ. Si la entrada es 1V, ¿cuál es el voltaje de salida?', + 10, 3, + ['Ganancia = 1 + Rf/R1 = 1 + 9 = 10. Vout = 10 × 1V'], + 0.1, + 'El amplificador no inversor:\n\nVin → entrada (+) del op-amp\n entrada (-) → [R1] → GND\n |\n [Rf] → Salida\n\nGanancia = 1 + Rf/R1\n\nEjemplo: Rf=15kΩ, R1=5kΩ → G = 1 + 15000/5000 = 4\nSi Vin=2V → Vout = 4 × 2 = 8V\n\nNo invierte la señal. La ganancia siempre es ≥ 1.' + ), +]; + +// ========================================================================= +// POWER SUPPLIES +// ========================================================================= +export const powerChallenges: Challenge[] = [ + { + id: 'electronics.power/pwr-01', + nodeId: 'electronics.power', + title: 'De AC a DC', + description: '¿Cuál es el orden correcto para convertir corriente alterna (AC) en corriente continua (DC) estable?', + difficulty: 3, + type: 'multiple-choice', + hints: ['Piensa en: transformar → rectificar → suavizar → regular'], + xpReward: 75, + explanation: 'Las fuentes de alimentación convierten AC (de la red, 230V~) a DC estable:\n\n1. Transformador: baja el voltaje (230V → 12V)\n2. Rectificador: convierte AC a DC pulsante (usando diodos)\n3. Filtro: suaviza el rizado con condensadores\n4. Regulador: estabiliza el voltaje final (ej: 5V exactos)\n\nCada etapa resuelve un problema:\n• Transformador → voltaje demasiado alto\n• Rectificador → corriente alterna\n• Filtro → picos y valles\n• Regulador → variaciones residuales', + content: { + type: 'multiple-choice', + question: '¿Cuál es el orden correcto para una fuente DC?', + options: [ + 'Regulador → Rectificador → Filtro → Transformador', + 'Transformador → Rectificador → Filtro → Regulador', + 'Rectificador → Transformador → Regulador → Filtro', + 'Filtro → Regulador → Transformador → Rectificador', + ], + correctIndex: 1, + }, + }, + + mathElecChallenge( + 'pwr-02', 'electronics.power', + 'Potencia disipada', + 'Un regulador de voltaje convierte 12V a 5V con una corriente de 500mA. ¿Cuántos watts disipa el regulador en calor?', + 3.5, 3, + ['P = (Vin - Vout) × I = (12-5) × 0.5'], + 0.1, + 'Un regulador lineal "quema" el exceso de voltaje como calor:\n\nP = (Vin - Vout) × I\n\nEjemplo: 9V→3.3V a 200mA → P = (9-3.3) × 0.2 = 1.14W\n\nPor eso los reguladores llevan disipador.\nLos reguladores switching son más eficientes (>90%) pero más complejos.' + ), + + mathElecChallenge( + 'pwr-03', 'electronics.power', + 'Condensador de filtrado', + 'Después del rectificador, necesitas un condensador para suavizar 5V con rizado <0.1V a 100mA y 50Hz. ¿Qué capacitancia mínima en µF? (redondeada)', + 20000, 3, + ['C = I / (f × Vrizado) = 0.1 / (50 × 0.1). El resultado es en Faradios, convierte a µF.'], + 1000, + 'El condensador de filtrado suaviza el voltaje pulsante del rectificador:\n\nC = I / (f × Vrizado)\n\nEjemplo: con 50mA, 60Hz y Vrizado=0.2V → C = 0.05/(60×0.2) = 4167µF\n\nCuanto menos rizado quieras, más capacitancia necesitas. Son condensadores grandes (electrolíticos).' + ), +]; + +// ========================================================================= +// EXPORT ALL +// ========================================================================= +export const allElectronicsChallenges: Challenge[] = [ + ...electricityBasicsChallenges, + ...seriesParallelChallenges, + ...capacitorChallenges, + ...diodeChallenges, + ...transistorChallenges, + ...opampChallenges, + ...powerChallenges, +]; diff --git a/src/data/challenges/math.ts b/src/data/challenges/math.ts index 784ef01..1ddbc1c 100644 --- a/src/data/challenges/math.ts +++ b/src/data/challenges/math.ts @@ -32,76 +32,76 @@ function mathChallenge( export const arithmeticChallenges: Challenge[] = [ // Addition mathChallenge('add-01', 'arithmetic.addition', 'Suma simple', '¿Cuánto es 7 + 5?', 12, 1, ['Cuenta desde 7 hacia arriba'], 0, - 'La suma es la operación más básica. Combina dos cantidades en una sola.\n\nPor ejemplo: si tienes 3 manzanas y te dan 2 más, ahora tienes 3 + 2 = 5 manzanas.\n\nTruco: para sumar mentalmente, empieza por el número más grande y cuenta hacia arriba. Por ejemplo, para 7 + 5: empieza en 7 y cuenta 5 más → 8, 9, 10, 11, 12.'), + 'La suma es la operación más básica. Combina dos cantidades en una sola.\n\nPor ejemplo: si tienes 3 manzanas y te dan 2 más, ahora tienes 3 + 2 = 5 manzanas.\n\nTruco: para sumar mentalmente, empieza por el número más grande y cuenta hacia arriba. Por ejemplo, para 8 + 6: empieza en 8 y cuenta 6 más → 9, 10, 11, 12, 13, 14.'), mathChallenge('add-02', 'arithmetic.addition', 'Suma de dos cifras', '¿Cuánto es 34 + 27?', 61, 1, ['Suma las unidades primero: 4+7=11']), mathChallenge('add-03', 'arithmetic.addition', 'Suma de tres números', '¿Cuánto es 15 + 23 + 42?', 80, 1, ['Suma de dos en dos']), mathChallenge('add-04', 'arithmetic.addition', 'Suma con centenas', '¿Cuánto es 256 + 189?', 445, 1, ['Empieza por las unidades: 6+9=15']), // Subtraction mathChallenge('sub-01', 'arithmetic.subtraction', 'Resta simple', '¿Cuánto es 15 - 8?', 7, 1, ['Cuenta hacia atrás desde 15'], 0, - 'La resta es la operación inversa de la suma. Quita una cantidad de otra.\n\nPor ejemplo: si tienes 10 galletas y comes 3, te quedan 10 - 3 = 7.\n\nTruco: puedes pensar "¿qué le sumo a 8 para llegar a 15?". Si 8 + 7 = 15, entonces 15 - 8 = 7.'), + 'La resta es la operación inversa de la suma. Quita una cantidad de otra.\n\nPor ejemplo: si tienes 13 galletas y comes 5, te quedan 13 - 5 = 8.\n\nTruco: puedes pensar "¿qué le sumo a 5 para llegar a 13?". Si 5 + 8 = 13, entonces 13 - 5 = 8.'), mathChallenge('sub-02', 'arithmetic.subtraction', 'Resta de dos cifras', '¿Cuánto es 82 - 47?', 35, 1, ['Necesitas "pedir prestado" en las unidades']), mathChallenge('sub-03', 'arithmetic.subtraction', 'Resta con centenas', '¿Cuánto es 500 - 237?', 263, 1), // Multiplication mathChallenge('mul-01', 'arithmetic.multiplication', 'Multiplicación básica', '¿Cuánto es 6 × 7?', 42, 1, ['Piensa en 6 grupos de 7'], 0, - 'La multiplicación es una suma repetida. 6 × 7 significa "sumar 7 seis veces" (o "sumar 6 siete veces").\n\n6 × 7 = 7 + 7 + 7 + 7 + 7 + 7 = 42\n\nAprender las tablas de multiplicar de memoria es muy útil. Truco: si no recuerdas 6×7, piensa en 6×5=30 y luego suma 6×2=12 → 30+12=42.'), + 'La multiplicación es una suma repetida. 4 × 8 significa "sumar 8 cuatro veces" (o "sumar 4 ocho veces").\n\n4 × 8 = 8 + 8 + 8 + 8 = 32\n\nAprender las tablas de multiplicar de memoria es muy útil. Truco: si no recuerdas 4×8, piensa en 4×5=20 y luego suma 4×3=12 → 20+12=32.'), mathChallenge('mul-02', 'arithmetic.multiplication', 'Multiplicación de dos cifras', '¿Cuánto es 12 × 15?', 180, 1, ['12 × 15 = 12 × 10 + 12 × 5']), mathChallenge('mul-03', 'arithmetic.multiplication', 'Multiplicación avanzada', '¿Cuánto es 25 × 32?', 800, 2, ['25 × 32 = 25 × 4 × 8']), // Division mathChallenge('div-01', 'arithmetic.division', 'División exacta', '¿Cuánto es 56 ÷ 8?', 7, 1, ['¿Qué número × 8 = 56?'], 0, - 'La división es la operación inversa de la multiplicación. Reparte una cantidad en partes iguales.\n\n56 ÷ 8 significa: "¿en cuántos grupos de 8 cabe 56?" o "si reparto 56 entre 8, ¿cuánto toca a cada uno?"\n\nTruco: piensa "¿qué número multiplicado por 8 da 56?". Como 7 × 8 = 56, entonces 56 ÷ 8 = 7.'), + 'La división es la operación inversa de la multiplicación. Reparte una cantidad en partes iguales.\n\n36 ÷ 6 significa: "¿en cuántos grupos de 6 cabe 36?" o "si reparto 36 entre 6, ¿cuánto toca a cada uno?"\n\nTruco: piensa "¿qué número multiplicado por 6 da 36?". Como 6 × 6 = 36, entonces 36 ÷ 6 = 6.'), mathChallenge('div-02', 'arithmetic.division', 'División de dos cifras', '¿Cuánto es 144 ÷ 12?', 12, 1), mathChallenge('div-03', 'arithmetic.division', 'División con decimales', '¿Cuánto es 7 ÷ 4?', 1.75, 2, ['Divide y continúa con decimales'], 0.01), ]; export const fractionChallenges: Challenge[] = [ mathChallenge('frac-01', 'arithmetic.fractions', 'Suma de fracciones', '¿Cuánto es 1/3 + 1/6?', 0.5, 2, ['Encuentra un denominador común: 6'], 0.01, - 'Las fracciones representan partes de un todo. 1/3 = "una de tres partes", 1/6 = "una de seis partes".\n\nPara sumar fracciones necesitas el mismo denominador (la parte de abajo):\n• 1/3 = 2/6 (multiplica arriba y abajo por 2)\n• 2/6 + 1/6 = 3/6 = 1/2 = 0.5\n\nRegla: busca el mínimo común denominador, convierte ambas fracciones, y luego suma los numeradores.'), + 'Las fracciones representan partes de un todo. 1/3 = "una de tres partes", 1/6 = "una de seis partes".\n\nPara sumar fracciones necesitas el mismo denominador (la parte de abajo):\n• 1/4 = 1/4\n• 1/2 = 2/4 (multiplica arriba y abajo por 2)\n• 1/4 + 2/4 = 3/4 = 0.75\n\nRegla: busca el mínimo común denominador, convierte ambas fracciones, y luego suma los numeradores.'), mathChallenge('frac-02', 'arithmetic.fractions', 'Multiplicación de fracciones', '¿Cuánto es 2/3 × 3/4?', 0.5, 2, ['Multiplica numerador con numerador y denominador con denominador'], 0.01), mathChallenge('frac-03', 'arithmetic.fractions', 'Fracción a decimal', '¿Cuánto es 5/8 en decimal?', 0.625, 2, ['Divide 5 entre 8'], 0.001), ]; export const decimalChallenges: Challenge[] = [ mathChallenge('dec-01', 'arithmetic.decimals', 'Suma de decimales', '¿Cuánto es 3.7 + 2.85?', 6.55, 2, ['Alinea los puntos decimales'], 0.01, - 'Los decimales son otra forma de escribir fracciones. 3.7 = 3 + 7/10, y 2.85 = 2 + 85/100.\n\nPara sumar decimales, alinea los puntos decimales y suma columna por columna:\n 3.70\n+ 2.85\n------\n 6.55\n\nTruco: si un número tiene menos decimales, añade ceros al final (3.7 → 3.70) para que sea más fácil alinearlos.'), + 'Los decimales son otra forma de escribir fracciones. 3.7 = 3 + 7/10, y 2.85 = 2 + 85/100.\n\nPara sumar decimales, alinea los puntos decimales y suma columna por columna:\n 2.30\n+ 1.45\n------\n 3.75\n\nTruco: si un número tiene menos decimales, añade ceros al final (2.3 → 2.30) para que sea más fácil alinearlos.'), mathChallenge('dec-02', 'arithmetic.decimals', 'Redondeo', 'Redondea 3.746 a dos decimales', 3.75, 2, ['Mira el tercer decimal: 6 ≥ 5, sube'], 0.001), mathChallenge('dec-03', 'arithmetic.decimals', 'Multiplicación decimal', '¿Cuánto es 2.5 × 0.4?', 1, 2, ['2.5 × 0.4 = 25 × 4 ÷ 100']), ]; export const percentageChallenges: Challenge[] = [ mathChallenge('pct-01', 'arithmetic.percentages', 'Porcentaje básico', '¿Cuánto es el 25% de 200?', 50, 2, ['25% = 1/4'], 0, - 'Porcentaje significa "por cada cien". 25% = 25/100 = 0.25\n\nPara calcular un porcentaje de un número, multiplica el número por el porcentaje en decimal:\n• 25% de 200 = 0.25 × 200 = 50\n\nAtajos útiles:\n• 50% = la mitad\n• 25% = un cuarto\n• 10% = mover el punto decimal una posición a la izquierda\n• 1% = mover el punto decimal dos posiciones'), + 'Porcentaje significa "por cada cien". 25% = 25/100 = 0.25\n\nPara calcular un porcentaje de un número, multiplica el número por el porcentaje en decimal:\n• 20% de 150 = 0.20 × 150 = 30\n\nAtajos útiles:\n• 50% = la mitad\n• 25% = un cuarto\n• 10% = mover el punto decimal una posición a la izquierda\n• 1% = mover el punto decimal dos posiciones'), mathChallenge('pct-02', 'arithmetic.percentages', 'Descuento', 'Un artículo cuesta 80€. Con 15% de descuento, ¿cuánto pagas?', 68, 2, ['Calcula el 15% de 80 y réstalo']), mathChallenge('pct-03', 'arithmetic.percentages', 'Porcentaje inverso', 'Si 30 es el 60% de un número, ¿cuál es ese número?', 50, 2, ['30 = 0.6 × x, entonces x = 30/0.6']), ]; export const primeChallenges: Challenge[] = [ mathChallenge('prime-01', 'number-theory.primes', '¿Es primo?', '¿Cuántos números primos hay entre 1 y 20?', 8, 2, ['Los primos son: 2, 3, 5, 7, 11, 13, 17, 19'], 0, - 'Un número primo es un número mayor que 1 que solo es divisible por 1 y por sí mismo.\n\nEjemplos:\n• 2 es primo (solo divisible por 1 y 2)\n• 4 NO es primo (divisible por 1, 2 y 4)\n• 7 es primo\n• 9 NO es primo (3 × 3 = 9)\n\nEl 1 no se considera primo. El 2 es el único primo par.\n\nPara verificar si un número es primo, comprueba si es divisible por algún número hasta su raíz cuadrada.'), + 'Un número primo es un número mayor que 1 que solo es divisible por 1 y por sí mismo.\n\nEntre 1 y 10, los primos son: 2, 3, 5, 7 (hay 4).\n\n• 2 es primo (solo divisible por 1 y 2)\n• 4 NO es primo (divisible por 1, 2 y 4)\n• 5 es primo\n• 9 NO es primo (3 × 3 = 9)\n\nEl 1 no se considera primo. El 2 es el único primo par.\n\nPara verificar si un número es primo, comprueba si es divisible por algún número hasta su raíz cuadrada.'), mathChallenge('prime-02', 'number-theory.primes', 'Factorización', '¿Cuál es el factor primo más grande de 84?', 7, 2, ['84 = 2 × 42 = 2 × 2 × 21 = 2 × 2 × 3 × 7']), mathChallenge('prime-03', 'number-theory.primes', 'Primo siguiente', '¿Cuál es el siguiente número primo después de 23?', 29, 2, ['Comprueba 24, 25, 26, 27, 28, 29...']), ]; export const gcdLcmChallenges: Challenge[] = [ mathChallenge('gcd-01', 'number-theory.gcd-lcm', 'MCD básico', '¿Cuál es el MCD de 24 y 36?', 12, 2, ['Factoriza: 24=2³×3, 36=2²×3²'], 0, - 'El MCD (Máximo Común Divisor) es el número más grande que divide a dos números exactamente.\n\nMétodo de factorización:\n1. Descompón cada número en factores primos:\n • 24 = 2 × 2 × 2 × 3 = 2³ × 3\n • 36 = 2 × 2 × 3 × 3 = 2² × 3²\n2. Toma los factores comunes con el menor exponente:\n • Factor 2: mín(3,2) = 2² = 4\n • Factor 3: mín(1,2) = 3¹ = 3\n3. Multiplica: 4 × 3 = 12\n\nEl MCD de 24 y 36 es 12.\n\nMétodo alternativo (Euclides): divide el mayor entre el menor, luego el menor entre el resto, repite hasta que el resto sea 0. El último divisor es el MCD.\n• 36 ÷ 24 = 1 resto 12\n• 24 ÷ 12 = 2 resto 0 → MCD = 12'), + 'El MCD (Máximo Común Divisor) es el número más grande que divide a dos números exactamente.\n\nMétodo de factorización:\n1. Descompón cada número en factores primos:\n • 18 = 2 × 3 × 3 = 2 × 3²\n • 12 = 2 × 2 × 3 = 2² × 3\n2. Toma los factores comunes con el menor exponente:\n • Factor 2: mín(1,2) = 2¹ = 2\n • Factor 3: mín(2,1) = 3¹ = 3\n3. Multiplica: 2 × 3 = 6\n\nEl MCD de 18 y 12 es 6.\n\nMétodo alternativo (Euclides): divide el mayor entre el menor, luego el menor entre el resto, repite hasta que el resto sea 0. El último divisor es el MCD.\n• 18 ÷ 12 = 1 resto 6\n• 12 ÷ 6 = 2 resto 0 → MCD = 6'), mathChallenge('gcd-02', 'number-theory.gcd-lcm', 'MCM básico', '¿Cuál es el MCM de 6 y 8?', 24, 2, ['MCM = (6×8)/MCD(6,8)'], 0, - 'El MCM (Mínimo Común Múltiplo) es el número más pequeño que es múltiplo de ambos números.\n\n¡No confundir con el MCD!\n• MCD = el mayor número que DIVIDE a ambos (busca divisores)\n• MCM = el menor número que es MÚLTIPLO de ambos (busca múltiplos)\n\nMétodo 1 — listar múltiplos:\n• Múltiplos de 6: 6, 12, 18, 24, 30...\n• Múltiplos de 8: 8, 16, 24, 32...\n• El primero en común es 24\n\nMétodo 2 — fórmula rápida:\nMCM(a,b) = (a × b) / MCD(a,b)\nMCM(6,8) = (6 × 8) / MCD(6,8) = 48 / 2 = 24\n\nEl MCM se usa mucho para sumar fracciones con distinto denominador.'), + 'El MCM (Mínimo Común Múltiplo) es el número más pequeño que es múltiplo de ambos números.\n\n¡No confundir con el MCD!\n• MCD = el mayor número que DIVIDE a ambos (busca divisores)\n• MCM = el menor número que es MÚLTIPLO de ambos (busca múltiplos)\n\nMétodo 1 — listar múltiplos:\n• Múltiplos de 4: 4, 8, 12, 16, 20...\n• Múltiplos de 6: 6, 12, 18, 24...\n• El primero en común es 12\n\nMétodo 2 — fórmula rápida:\nMCM(a,b) = (a × b) / MCD(a,b)\nMCM(4,6) = (4 × 6) / MCD(4,6) = 24 / 2 = 12\n\nEl MCM se usa mucho para sumar fracciones con distinto denominador.'), mathChallenge('gcd-03', 'number-theory.gcd-lcm', 'MCD de tres números', '¿Cuál es el MCD de 12, 18 y 30?', 6, 2), ]; export const variableChallenges: Challenge[] = [ mathChallenge('var-01', 'algebra.variables', 'Evaluar expresión', 'Si x = 3, ¿cuánto vale 2x + 5?', 11, 2, ['Sustituye x por 3: 2(3) + 5'], 0, - 'En álgebra usamos letras (variables) para representar números desconocidos.\n\n"2x" significa "2 multiplicado por x". Si x = 3:\n• 2x = 2 × 3 = 6\n• 2x + 5 = 6 + 5 = 11\n\nEvaluar una expresión es sustituir la variable por su valor y calcular el resultado. Siempre resuelve multiplicaciones antes que sumas (orden de operaciones).'), + 'En álgebra usamos letras (variables) para representar números desconocidos.\n\n"3x" significa "3 multiplicado por x". Si x = 4:\n• 3x = 3 × 4 = 12\n• 3x + 2 = 12 + 2 = 14\n\nEvaluar una expresión es sustituir la variable por su valor y calcular el resultado. Siempre resuelve multiplicaciones antes que sumas (orden de operaciones).'), mathChallenge('var-02', 'algebra.variables', 'Expresión con dos variables', 'Si a = 4 y b = 7, ¿cuánto vale 3a - b + 2?', 7, 2, ['3(4) - 7 + 2 = 12 - 7 + 2']), mathChallenge('var-03', 'algebra.variables', 'Simplificar', 'Simplifica: 3x + 2x - x. ¿Cuántos "x" quedan?', 4, 2, ['Suma los coeficientes: 3+2-1']), ]; export const equationChallenges: Challenge[] = [ mathChallenge('eq-01', 'algebra.equations', 'Ecuación simple', 'Resuelve: x + 7 = 15', 8, 3, ['Resta 7 de ambos lados'], 0, - 'Una ecuación es una igualdad con una incógnita (x). Resolverla es encontrar el valor de x.\n\nRegla de oro: lo que hagas a un lado, hazlo al otro.\n\nEjemplo: x + 7 = 15\n• Queremos x sola → restamos 7 de ambos lados\n• x + 7 - 7 = 15 - 7\n• x = 8\n\nComprobación: 8 + 7 = 15 ✓\n\nOperaciones inversas: suma↔resta, multiplicación↔división.'), + 'Una ecuación es una igualdad con una incógnita (x). Resolverla es encontrar el valor de x.\n\nRegla de oro: lo que hagas a un lado, hazlo al otro.\n\nEjemplo: x + 5 = 12\n• Queremos x sola → restamos 5 de ambos lados\n• x + 5 - 5 = 12 - 5\n• x = 7\n\nComprobación: 7 + 5 = 12 ✓\n\nOperaciones inversas: suma↔resta, multiplicación↔división.'), mathChallenge('eq-02', 'algebra.equations', 'Ecuación con multiplicación', 'Resuelve: 3x = 21', 7, 3, ['Divide ambos lados entre 3']), mathChallenge('eq-03', 'algebra.equations', 'Ecuación de dos pasos', 'Resuelve: 2x + 5 = 17', 6, 3, ['Primero resta 5, luego divide entre 2']), mathChallenge('eq-04', 'algebra.equations', 'Ecuación con paréntesis', 'Resuelve: 3(x - 2) = 15', 7, 3, ['Distribuye: 3x - 6 = 15']), @@ -109,14 +109,14 @@ export const equationChallenges: Challenge[] = [ export const linearSystemChallenges: Challenge[] = [ mathChallenge('sys-01', 'algebra.linear-systems', 'Sistema simple', 'Resuelve: x + y = 10, x - y = 2. ¿Cuánto vale x?', 6, 3, ['Suma ambas ecuaciones: 2x = 12'], 0, - 'Un sistema de ecuaciones son dos (o más) ecuaciones que deben cumplirse a la vez.\n\nMétodo de eliminación:\n1. Suma o resta las ecuaciones para eliminar una variable\n2. Resuelve la variable que queda\n3. Sustituye para encontrar la otra\n\nEjemplo:\n x + y = 10\n x - y = 2\n\nSumando ambas: (x+y) + (x-y) = 10+2 → 2x = 12 → x = 6\nSustituyendo: 6 + y = 10 → y = 4'), + 'Un sistema de ecuaciones son dos (o más) ecuaciones que deben cumplirse a la vez.\n\nMétodo de eliminación:\n1. Suma o resta las ecuaciones para eliminar una variable\n2. Resuelve la variable que queda\n3. Sustituye para encontrar la otra\n\nEjemplo:\n x + y = 7\n x - y = 3\n\nSumando ambas: (x+y) + (x-y) = 7+3 → 2x = 10 → x = 5\nSustituyendo: 5 + y = 7 → y = 2'), mathChallenge('sys-02', 'algebra.linear-systems', 'Sistema por sustitución', 'Resuelve: y = 2x, x + y = 9. ¿Cuánto vale x?', 3, 3, ['Sustituye y: x + 2x = 9']), mathChallenge('sys-03', 'algebra.linear-systems', 'Sistema avanzado', 'Resuelve: 2x + 3y = 16, x - y = 3. ¿Cuánto vale y?', 2, 3, ['De la segunda: x = y + 3. Sustituye en la primera.']), ]; export const quadraticChallenges: Challenge[] = [ mathChallenge('quad-01', 'algebra.quadratics', 'Cuadrática simple', 'Resuelve: x² = 25. Da la solución positiva.', 5, 3, ['√25 = 5'], 0, - 'Una ecuación cuadrática contiene x² (x al cuadrado). La forma general es ax² + bx + c = 0.\n\nEl caso más simple: x² = número\n• Solución: x = ±√número\n• x² = 25 → x = +5 o x = -5 (porque tanto 5×5 como (-5)×(-5) dan 25)\n\nPara ecuaciones más complejas, se usa la fórmula general:\nx = (-b ± √(b²-4ac)) / 2a\n\nO se intenta factorizar: x²-5x+6 = (x-2)(x-3) = 0 → x=2 o x=3'), + 'Una ecuación cuadrática contiene x² (x al cuadrado). La forma general es ax² + bx + c = 0.\n\nEl caso más simple: x² = número\n• Solución: x = ±√número\n• x² = 16 → x = +4 o x = -4 (porque tanto 4×4 como (-4)×(-4) dan 16)\n\nPara ecuaciones más complejas, se usa la fórmula general:\nx = (-b ± √(b²-4ac)) / 2a\n\nO se intenta factorizar: x²-5x+6 = (x-2)(x-3) = 0 → x=2 o x=3'), mathChallenge('quad-02', 'algebra.quadratics', 'Factorización', 'Resuelve: x² - 5x + 6 = 0. Da la solución mayor.', 3, 3, ['Factoriza: (x-2)(x-3) = 0']), mathChallenge('quad-03', 'algebra.quadratics', 'Fórmula general', 'Resuelve: x² + 2x - 8 = 0. Da la solución positiva.', 2, 3, ['Usa: x = (-b ± √(b²-4ac)) / 2a']), ]; @@ -175,11 +175,14 @@ export const booleanChallenges: Challenge[] = [ export const binaryChallenges: Challenge[] = [ mathChallenge('bin-01', 'logic.binary', 'Decimal a binario', '¿Cuánto es 13 en binario? (escribe el número decimal que forman los dígitos binarios, ej: 1101)', 1101, 2, ['13 = 8+4+1 = 1101₂'], 0, - 'El sistema binario usa solo dos dígitos: 0 y 1. Cada posición vale el doble que la anterior (de derecha a izquierda):\n\n... 16 8 4 2 1\n\nPara convertir decimal a binario, descompón en potencias de 2:\n• 13 = 8 + 4 + 1\n• 13 = 1×8 + 1×4 + 0×2 + 1×1\n• 13 en binario = 1101\n\nMétodo alternativo: divide entre 2 repetidamente y lee los restos de abajo arriba:\n• 13÷2 = 6 resto 1\n• 6÷2 = 3 resto 0\n• 3÷2 = 1 resto 1\n• 1÷2 = 0 resto 1\n→ 1101'), + 'El sistema binario usa solo dos dígitos: 0 y 1. Cada posición vale el doble que la anterior (de derecha a izquierda):\n\n... 16 8 4 2 1\n\nPara convertir decimal a binario, descompón en potencias de 2:\n• 11 = 8 + 2 + 1\n• 11 = 1×8 + 0×4 + 1×2 + 1×1\n• 11 en binario = 1011\n\nMétodo alternativo: divide entre 2 repetidamente y lee los restos de abajo arriba:\n• 11÷2 = 5 resto 1\n• 5÷2 = 2 resto 1\n• 2÷2 = 1 resto 0\n• 1÷2 = 0 resto 1\n→ 1011'), mathChallenge('bin-02', 'logic.binary', 'Binario a decimal', '¿Cuánto es 10110 en decimal?', 22, 2, ['1×16 + 0×8 + 1×4 + 1×2 + 0×1']), mathChallenge('bin-03', 'logic.binary', 'Suma binaria', '¿Cuánto es 1010 + 0110 en decimal?', 16, 2, ['1010=10, 0110=6, 10+6=16']), ]; +import { allCircuitChallenges } from './circuits'; +import { allElectronicsChallenges } from './electronics'; + export const allChallenges: Challenge[] = [ ...arithmeticChallenges, ...fractionChallenges, @@ -193,6 +196,8 @@ export const allChallenges: Challenge[] = [ ...quadraticChallenges, ...booleanChallenges, ...binaryChallenges, + ...allCircuitChallenges, + ...allElectronicsChallenges, ]; export function getChallengeById(id: string): Challenge | undefined { diff --git a/src/data/skill-tree.ts b/src/data/skill-tree.ts index 11da117..8ca7b72 100644 --- a/src/data/skill-tree.ts +++ b/src/data/skill-tree.ts @@ -136,7 +136,7 @@ export const skillNodes: SkillNode[] = [ description: 'Introduce variables y evalúa expresiones algebraicas', icon: '𝑥', position: { x: CENTER - COL_GAP / 2, y: row(5) }, - prerequisites: ['arithmetic.percentages'], + prerequisites: ['arithmetic.division'], challenges: ['var-01', 'var-02', 'var-03'], difficulty: 2, }, @@ -148,7 +148,7 @@ export const skillNodes: SkillNode[] = [ description: 'AND, OR, NOT — las bases del pensamiento lógico', icon: '🧠', position: { x: CENTER + COL_GAP, y: row(5) }, - prerequisites: ['number-theory.primes'], + prerequisites: ['arithmetic.division'], challenges: ['bool-01', 'bool-02', 'bool-03'], difficulty: 2, }, @@ -204,6 +204,812 @@ export const skillNodes: SkillNode[] = [ challenges: ['quad-01', 'quad-02', 'quad-03'], difficulty: 3, }, + + // === ROW 7 (math-physics branch): Electronics Basics === + // ========================================================================= + // ELECTRONICS — from Ohm's law to analog circuits + // ========================================================================= + { + id: 'electronics.basics', + discipline: 'electronics', + branch: 'electronics', + title: 'Electricidad Básica', + description: 'Voltaje, corriente, resistencia y la Ley de Ohm', + icon: '🔋', + position: { x: 0, y: 0 }, + prerequisites: ['arithmetic.decimals'], + challenges: ['eb-01', 'eb-02', 'eb-03', 'eb-04'], + difficulty: 2, + }, + { + id: 'electronics.series-parallel', + discipline: 'electronics', + branch: 'electronics', + title: 'Serie y Paralelo', + description: 'Resistencias en serie y paralelo, divisores de voltaje y corriente', + icon: '🔀', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.basics'], + challenges: ['sp-01', 'sp-02', 'sp-03', 'sp-04'], + difficulty: 2, + }, + { + id: 'electronics.capacitors', + discipline: 'electronics', + branch: 'electronics', + title: 'Condensadores', + description: 'Almacena energía en campo eléctrico — carga, descarga, filtrado y constante RC', + icon: '⚡', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.series-parallel'], + challenges: ['cap-01', 'cap-02', 'cap-03', 'cap-04', 'cap-05'], + difficulty: 3, + }, + { + id: 'electronics.diodes', + discipline: 'electronics', + branch: 'electronics', + title: 'Diodos y LEDs', + description: 'Semiconductores básicos — conducción en un solo sentido, rectificación, LEDs', + icon: '💡', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.series-parallel'], + challenges: ['diode-01', 'diode-02', 'diode-03', 'diode-04'], + difficulty: 3, + }, + { + id: 'electronics.transistors', + discipline: 'electronics', + branch: 'electronics', + title: 'Transistores', + description: 'MOSFET como interruptor — la base de toda la electrónica digital', + icon: '🔌', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.diodes', 'logic.boolean'], + challenges: ['tr-01', 'tr-02', 'tr-03', 'tr-04'], + difficulty: 3, + }, + { + id: 'electronics.opamp', + discipline: 'electronics', + branch: 'electronics', + title: 'Amplificador Operacional', + description: 'El bloque fundamental analógico — comparador, amplificador, filtro activo', + icon: '📐', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.transistors'], + challenges: ['opamp-01', 'opamp-02', 'opamp-03'], + difficulty: 4, + }, + { + id: 'electronics.power', + discipline: 'electronics', + branch: 'electronics', + title: 'Fuentes de Alimentación', + description: 'Reguladores, rectificación, filtrado — de la red eléctrica a voltaje DC estable', + icon: '🔌', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.capacitors', 'electronics.diodes'], + challenges: ['pwr-01', 'pwr-02', 'pwr-03'], + difficulty: 3, + }, + + // ========================================================================= + // DIGITAL CIRCUITS — gates, combinational (from logic + transistors) + // ========================================================================= + { + id: 'electronics.logic-gates', + discipline: 'digital-circuits', + branch: 'digital', + title: 'Puertas Lógicas', + description: 'Construye circuitos con puertas AND, OR, NOT, NAND y XOR', + icon: '⚡', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.transistors', 'logic.binary'], + challenges: ['lg-01', 'lg-02', 'lg-03', 'lg-04', 'lg-05', 'lg-06', 'lg-07', 'lg-08'], + difficulty: 3, + }, + { + id: 'electronics.combinational', + discipline: 'digital-circuits', + branch: 'digital', + title: 'Circuitos Combinacionales', + description: 'Combina puertas para construir sumadores y multiplexores', + icon: '🔧', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.logic-gates'], + challenges: ['cc-01', 'cc-02', 'cc-03', 'cc-04'], + difficulty: 4, + }, + + // ========================================================================= + // SEQUENTIAL CIRCUITS — flip-flops, registers, counters + // ========================================================================= + { + id: 'sequential.flipflops', + discipline: 'sequential-circuits', + branch: 'sequential', + title: 'Flip-Flops', + description: 'SR Latch, D Flip-Flop — circuitos que recuerdan un bit', + icon: '🔄', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.combinational'], + challenges: [], // placeholder + difficulty: 3, + }, + { + id: 'sequential.registers', + discipline: 'sequential-circuits', + branch: 'sequential', + title: 'Registros', + description: 'Almacena múltiples bits — registros de 8 bits y shift registers', + icon: '📦', + position: { x: 0, y: 0 }, + prerequisites: ['sequential.flipflops'], + challenges: [], + difficulty: 3, + }, + { + id: 'sequential.counters', + discipline: 'sequential-circuits', + branch: 'sequential', + title: 'Contadores y Reloj', + description: 'Contadores binarios, señal de reloj y temporización', + icon: '⏱️', + position: { x: 0, y: 0 }, + prerequisites: ['sequential.flipflops'], + challenges: [], + difficulty: 3, + }, + + // ========================================================================= + // COMPUTER ARCHITECTURE — ALU, Memory, Control Unit, CPU + // ========================================================================= + { + id: 'cpu.alu', + discipline: 'computer-architecture', + branch: 'cpu', + title: 'ALU', + description: 'Unidad Aritmético-Lógica — suma, resta, AND, OR, XOR en hardware', + icon: '🧮', + position: { x: 0, y: 0 }, + prerequisites: ['electronics.combinational', 'sequential.registers'], + challenges: [], + difficulty: 4, + }, + { + id: 'cpu.memory', + discipline: 'computer-architecture', + branch: 'cpu', + title: 'Memoria', + description: 'SRAM, ROM y el bus de datos — almacenamiento de programas y datos', + icon: '💾', + position: { x: 0, y: 0 }, + prerequisites: ['sequential.registers'], + challenges: [], + difficulty: 4, + }, + { + id: 'cpu.control-isa', + discipline: 'computer-architecture', + branch: 'cpu', + title: 'Unidad de Control e ISA', + description: 'Diseña tu set de instrucciones (ISA) y la unidad de control que las decodifica — fetch, decode, execute', + icon: '🎛️', + position: { x: 0, y: 0 }, + prerequisites: ['cpu.alu', 'cpu.memory', 'sequential.counters'], + challenges: [], + difficulty: 5, + }, + { + id: 'cpu.complete', + discipline: 'computer-architecture', + branch: 'cpu', + title: 'Tu CPU de 8 bits', + description: 'Ensambla ALU + Registros + Memoria + Control. Escribe tu primer programa: se ejecuta en TU hardware.', + icon: '🖥️', + position: { x: 0, y: 0 }, + prerequisites: ['cpu.control-isa'], + challenges: [], + difficulty: 5, + }, + + // ========================================================================= + // PROGRAMMING YOUR CPU — stack, subroutines, interrupts + // (you already know ISA/ASM from building the CPU — now go deeper) + // ========================================================================= + { + id: 'asm.stack-subroutines', + discipline: 'low-level-programming', + branch: 'asm', + title: 'Pila y Subrutinas', + description: 'PUSH, POP, CALL, RET — añade un stack pointer a tu CPU y haz funciones', + icon: '📚', + position: { x: 0, y: 0 }, + prerequisites: ['cpu.complete'], + challenges: [], + difficulty: 4, + }, + { + id: 'asm.interrupts', + discipline: 'low-level-programming', + branch: 'asm', + title: 'Interrupciones y I/O', + description: 'Añade interrupciones a tu CPU — el hardware puede pedir atención al software', + icon: '🔔', + position: { x: 0, y: 0 }, + prerequisites: ['asm.stack-subroutines'], + challenges: [], + difficulty: 4, + }, + + // ========================================================================= + // SIGNAL PROCESSING — Waves, Trigonometry applied + // ========================================================================= + { + id: 'signals.trigonometry', + discipline: 'signal-processing', + branch: 'signals', + title: 'Trigonometría', + description: 'Seno, coseno, tangente — las funciones que describen ondas', + icon: '📐', + position: { x: 0, y: 0 }, + prerequisites: ['algebra.quadratics'], + challenges: [], + difficulty: 3, + }, + { + id: 'signals.waves', + discipline: 'signal-processing', + branch: 'signals', + title: 'Ondas y Frecuencia', + description: 'Amplitud, frecuencia, fase — anatomía de una señal', + icon: '🌊', + position: { x: 0, y: 0 }, + prerequisites: ['signals.trigonometry'], + challenges: [], + difficulty: 3, + }, + { + id: 'signals.sampling', + discipline: 'signal-processing', + branch: 'signals', + title: 'Muestreo y DAC/ADC', + description: 'Convierte señales analógicas a digitales y viceversa — Nyquist, aliasing', + icon: '📊', + position: { x: 0, y: 0 }, + prerequisites: ['signals.waves', 'logic.binary'], + challenges: [], + difficulty: 3, + }, + + // ========================================================================= + // AUDIO SYNTHESIS — Oscillators, Filters, Synth, Sound Card + // ========================================================================= + { + id: 'audio.oscillators', + discipline: 'audio-synthesis', + branch: 'audio', + title: 'Osciladores', + description: 'Sine, square, sawtooth, triangle — genera sonido desde cero', + icon: '🎵', + position: { x: 0, y: 0 }, + prerequisites: ['signals.waves'], + challenges: [], + difficulty: 3, + }, + { + id: 'audio.filters', + discipline: 'audio-synthesis', + branch: 'audio', + title: 'Filtros y Envolventes', + description: 'Low-pass, high-pass, ADSR — moldea el timbre y la dinámica del sonido', + icon: '🎚️', + position: { x: 0, y: 0 }, + prerequisites: ['audio.oscillators'], + challenges: [], + difficulty: 3, + }, + { + id: 'audio.synthesizer', + discipline: 'audio-synthesis', + branch: 'audio', + title: 'Sintetizador', + description: 'Combina osciladores, filtros y efectos para crear tu propio instrumento', + icon: '🎹', + position: { x: 0, y: 0 }, + prerequisites: ['audio.filters'], + challenges: [], + difficulty: 4, + }, + { + id: 'audio.sound-card', + discipline: 'audio-synthesis', + branch: 'audio', + title: 'Tarjeta de Sonido', + description: 'Diseña el periférico de audio para tu CPU — registros, canales y reproducción', + icon: '🔊', + position: { x: 0, y: 0 }, + prerequisites: ['audio.synthesizer', 'asm.interrupts', 'signals.sampling'], + challenges: [], + difficulty: 5, + }, + + // ========================================================================= + // GRAPHICS — Pixels, Framebuffer, Sprites, Tiles + // ========================================================================= + { + id: 'graphics.pixels', + discipline: 'graphics', + branch: 'graphics', + title: 'Píxeles y Color', + description: 'RGB, paletas de color, resolución — la base de toda imagen digital', + icon: '🎨', + position: { x: 0, y: 0 }, + prerequisites: ['logic.binary'], + challenges: [], + difficulty: 2, + }, + { + id: 'graphics.framebuffer', + discipline: 'graphics', + branch: 'graphics', + title: 'Framebuffer y VRAM', + description: 'Memoria de vídeo, escaneo de pantalla y cómo un CPU dibuja en pantalla', + icon: '🖼️', + position: { x: 0, y: 0 }, + prerequisites: ['graphics.pixels', 'cpu.memory'], + challenges: [], + difficulty: 3, + }, + { + id: 'graphics.sprites', + discipline: 'graphics', + branch: 'graphics', + title: 'Sprites', + description: 'Objetos gráficos independientes — definición, posición, animación por frames', + icon: '👾', + position: { x: 0, y: 0 }, + prerequisites: ['graphics.framebuffer'], + challenges: [], + difficulty: 3, + }, + { + id: 'graphics.tilemaps', + discipline: 'graphics', + branch: 'graphics', + title: 'Tile Maps', + description: 'Mundos grandes con piezas pequeñas — mapas de tiles, scroll y capas', + icon: '🗺️', + position: { x: 0, y: 0 }, + prerequisites: ['graphics.sprites'], + challenges: [], + difficulty: 4, + }, + { + id: 'graphics.gpu', + discipline: 'graphics', + branch: 'graphics', + title: 'Tarjeta Gráfica (PPU)', + description: 'Diseña tu Picture Processing Unit — el periférico de vídeo de tu consola', + icon: '📺', + position: { x: 0, y: 0 }, + prerequisites: ['graphics.tilemaps', 'asm.interrupts'], + challenges: [], + difficulty: 5, + }, + + // ========================================================================= + // GAME DEV — Input, Game Loop, Final Project + // ========================================================================= + { + id: 'game.input', + discipline: 'game-dev', + branch: 'game', + title: 'Input y Controlador', + description: 'Lee el mando/teclado desde tu CPU — registros de input, polling y debounce', + icon: '🎮', + position: { x: 0, y: 0 }, + prerequisites: ['asm.interrupts'], + challenges: [], + difficulty: 3, + }, + { + id: 'game.gameloop', + discipline: 'game-dev', + branch: 'game', + title: 'Game Loop', + description: 'El bucle principal: input → lógica → render. Timing, VBlank y frames', + icon: '🔁', + position: { x: 0, y: 0 }, + prerequisites: ['game.input', 'graphics.gpu'], + challenges: [], + difficulty: 4, + }, + { + id: 'game.collision', + discipline: 'game-dev', + branch: 'game', + title: 'Colisiones y Física', + description: 'Detección de colisiones AABB, gravedad simple y movimiento de personajes', + icon: '💥', + position: { x: 0, y: 0 }, + prerequisites: ['game.gameloop'], + challenges: [], + difficulty: 4, + }, + { + id: 'game.sound-integration', + discipline: 'game-dev', + branch: 'game', + title: 'Integración de Audio', + description: 'Música y efectos de sonido en tu juego — canales, triggers y secuencias', + icon: '🎶', + position: { x: 0, y: 0 }, + prerequisites: ['game.gameloop', 'audio.sound-card'], + challenges: [], + difficulty: 4, + }, + { + id: 'game.final', + discipline: 'game-dev', + branch: 'game', + title: '🏆 Tu Juego', + description: 'Crea tu propio juego estilo NES: gráficos, sonido, input y lógica en TU consola', + icon: '🏆', + position: { x: 0, y: 0 }, + prerequisites: ['game.collision', 'game.sound-integration'], + challenges: [], + difficulty: 5, + }, + + // ========================================================================= + // HDL / VERILOG — describe hardware with code + // ========================================================================= + { + id: 'hdl.basics', + discipline: 'digital-circuits', + branch: 'hdl', + title: 'Intro a Verilog', + description: 'Describe hardware con código — modules, wires, assign, always blocks', + icon: '📝', + position: { x: 0, y: 0 }, + prerequisites: ['cpu.complete'], + challenges: [], + difficulty: 4, + }, + { + id: 'hdl.testbench', + discipline: 'digital-circuits', + branch: 'hdl', + title: 'Testbenches y Simulación', + description: 'Verifica tu hardware antes de fabricarlo — escribe tests, simula señales, analiza timing', + icon: '🧪', + position: { x: 0, y: 0 }, + prerequisites: ['hdl.basics'], + challenges: [], + difficulty: 4, + }, + { + id: 'hdl.cpu-verilog', + discipline: 'digital-circuits', + branch: 'hdl', + title: 'Tu CPU en Verilog', + description: 'Reimplementa tu CPU de 8 bits en Verilog — ahora puedes iterar rápido', + icon: '🔄', + position: { x: 0, y: 0 }, + prerequisites: ['hdl.testbench'], + challenges: [], + difficulty: 5, + }, + + // ========================================================================= + // EXTENDED ARCHITECTURE — 16/32 bit, MMU, peripherals (now using Verilog) + // ========================================================================= + { + id: 'ext-arch.16bit', + discipline: 'extended-architecture', + branch: 'ext-arch', + title: 'Ampliación a 16 bits', + description: 'Extiende tu CPU en Verilog: bus de 16 bits, más registros, direccionamiento extendido', + icon: '📏', + position: { x: 0, y: 0 }, + prerequisites: ['hdl.cpu-verilog'], + challenges: [], + difficulty: 4, + }, + { + id: 'ext-arch.32bit', + discipline: 'extended-architecture', + branch: 'ext-arch', + title: 'Arquitectura de 32 bits', + description: 'Bus de 32 bits, pipeline de instrucciones, caché L1 — un procesador "de verdad"', + icon: '🏗️', + position: { x: 0, y: 0 }, + prerequisites: ['ext-arch.16bit'], + challenges: [], + difficulty: 5, + }, + { + id: 'ext-arch.mmu', + discipline: 'extended-architecture', + branch: 'ext-arch', + title: 'MMU y Memoria Virtual', + description: 'Paginación, tablas de páginas, espacios de direcciones — cada proceso cree que tiene toda la RAM', + icon: '🗂️', + position: { x: 0, y: 0 }, + prerequisites: ['ext-arch.32bit'], + challenges: [], + difficulty: 5, + }, + { + id: 'ext-arch.peripherals', + discipline: 'extended-architecture', + branch: 'ext-arch', + title: 'Bus de Periféricos', + description: 'Buses I/O, DMA, controladores — cómo el CPU habla con el mundo exterior', + icon: '🔌', + position: { x: 0, y: 0 }, + prerequisites: ['ext-arch.32bit'], + challenges: [], + difficulty: 4, + }, + + // ========================================================================= + // OPERATING SYSTEMS — Bootloader, Kernel, Filesystem, Processes + // ========================================================================= + { + id: 'os.bootloader', + discipline: 'operating-systems', + branch: 'os', + title: 'Bootloader', + description: 'El primer código que ejecuta tu CPU al encender — carga el kernel desde disco', + icon: '🔑', + position: { x: 0, y: 0 }, + prerequisites: ['ext-arch.peripherals', 'hlp.c-basics', 'asm.interrupts'], + challenges: [], + difficulty: 4, + }, + { + id: 'os.kernel', + discipline: 'operating-systems', + branch: 'os', + title: 'Kernel', + description: 'El corazón del sistema operativo — syscalls, modo kernel vs usuario, scheduler básico', + icon: '🧬', + position: { x: 0, y: 0 }, + prerequisites: ['os.bootloader', 'ext-arch.mmu'], + challenges: [], + difficulty: 5, + }, + { + id: 'os.processes', + discipline: 'operating-systems', + branch: 'os', + title: 'Procesos y Threads', + description: 'Multitarea, context switching, scheduling — ejecuta varios programas a la vez', + icon: '🔀', + position: { x: 0, y: 0 }, + prerequisites: ['os.kernel'], + challenges: [], + difficulty: 5, + }, + { + id: 'os.filesystem', + discipline: 'operating-systems', + branch: 'os', + title: 'Sistema de Archivos', + description: 'FAT, inodos, directorios — organiza datos en disco', + icon: '📁', + position: { x: 0, y: 0 }, + prerequisites: ['os.kernel'], + challenges: [], + difficulty: 4, + }, + { + id: 'os.drivers', + discipline: 'operating-systems', + branch: 'os', + title: 'Drivers', + description: 'Controladores de dispositivo — el puente entre hardware y software', + icon: '🔧', + position: { x: 0, y: 0 }, + prerequisites: ['os.kernel', 'ext-arch.peripherals'], + challenges: [], + difficulty: 4, + }, + + // ========================================================================= + // HIGH-LEVEL PROGRAMMING — C, stdlib, data structures, algorithms + // ========================================================================= + { + id: 'hlp.c-basics', + discipline: 'high-level-programming', + branch: 'hlp', + title: 'Lenguaje C', + description: 'De ASM a C — variables, tipos, punteros, structs y funciones', + icon: '©️', + position: { x: 0, y: 0 }, + prerequisites: ['asm.stack-subroutines'], + challenges: [], + difficulty: 3, + }, + { + id: 'hlp.pointers-memory', + discipline: 'high-level-programming', + branch: 'hlp', + title: 'Punteros y Memoria', + description: 'malloc, free, stack vs heap, punteros a punteros — el poder (y peligro) de C', + icon: '🎯', + position: { x: 0, y: 0 }, + prerequisites: ['hlp.c-basics'], + challenges: [], + difficulty: 4, + }, + { + id: 'hlp.data-structures', + discipline: 'high-level-programming', + branch: 'hlp', + title: 'Estructuras de Datos', + description: 'Arrays, listas enlazadas, pilas, colas, árboles, hash tables en C', + icon: '🌳', + position: { x: 0, y: 0 }, + prerequisites: ['hlp.pointers-memory'], + challenges: [], + difficulty: 4, + }, + { + id: 'hlp.algorithms', + discipline: 'high-level-programming', + branch: 'hlp', + title: 'Algoritmos', + description: 'Sorting, searching, grafos, complejidad Big O', + icon: '⚡', + position: { x: 0, y: 0 }, + prerequisites: ['hlp.data-structures'], + challenges: [], + difficulty: 4, + }, + { + id: 'hlp.compiler', + discipline: 'high-level-programming', + branch: 'hlp', + title: 'Tu Compilador', + description: 'Lexer, parser, generación de código — compila C a tu propio ASM', + icon: '🏭', + position: { x: 0, y: 0 }, + prerequisites: ['hlp.c-basics', 'cpu.complete'], + challenges: [], + difficulty: 5, + }, + + // ========================================================================= + // NETWORKING — OSI model, protocols, sockets + // ========================================================================= + { + id: 'net.physical', + discipline: 'networking', + branch: 'net', + title: 'Capa Física y Enlace', + description: 'Señales eléctricas, tramas, MAC addresses, Ethernet — capas 1 y 2 del modelo OSI', + icon: '🔌', + position: { x: 0, y: 0 }, + prerequisites: ['os.drivers', 'signals.sampling'], + challenges: [], + difficulty: 3, + }, + { + id: 'net.ip', + discipline: 'networking', + branch: 'net', + title: 'IP y Routing', + description: 'Direcciones IP, subnets, routing tables, ICMP — capa 3, el camino entre nodos', + icon: '🌐', + position: { x: 0, y: 0 }, + prerequisites: ['net.physical'], + challenges: [], + difficulty: 3, + }, + { + id: 'net.tcp-udp', + discipline: 'networking', + branch: 'net', + title: 'TCP y UDP', + description: 'Puertos, handshake, control de flujo, fiabilidad — capa 4, transporte de datos', + icon: '📡', + position: { x: 0, y: 0 }, + prerequisites: ['net.ip'], + challenges: [], + difficulty: 4, + }, + { + id: 'net.sockets', + discipline: 'networking', + branch: 'net', + title: 'Sockets', + description: 'La API para enviar y recibir datos — bind, listen, accept, connect, send, recv', + icon: '🔗', + position: { x: 0, y: 0 }, + prerequisites: ['net.tcp-udp', 'hlp.c-basics'], + challenges: [], + difficulty: 4, + }, + { + id: 'net.dns', + discipline: 'networking', + branch: 'net', + title: 'DNS', + description: 'De nombres a IPs — resuelve dominios, construye un resolver', + icon: '📖', + position: { x: 0, y: 0 }, + prerequisites: ['net.sockets'], + challenges: [], + difficulty: 3, + }, + + // ========================================================================= + // WEB — HTTP, Server, Client, Final project + // ========================================================================= + { + id: 'web.http', + discipline: 'web', + branch: 'web', + title: 'HTTP', + description: 'Requests, responses, métodos, headers, status codes — el protocolo de la web', + icon: '📄', + position: { x: 0, y: 0 }, + prerequisites: ['net.tcp-udp', 'net.dns'], + challenges: [], + difficulty: 3, + }, + { + id: 'web.server', + discipline: 'web', + branch: 'web', + title: 'Servidor Web', + description: 'Construye un servidor HTTP desde cero con sockets — escucha, parsea, responde', + icon: '🖧', + position: { x: 0, y: 0 }, + prerequisites: ['web.http', 'net.sockets'], + challenges: [], + difficulty: 4, + }, + { + id: 'web.html-css', + discipline: 'web', + branch: 'web', + title: 'HTML y CSS', + description: 'Estructura y estilo de páginas web — lo que tu servidor envía al cliente', + icon: '🎨', + position: { x: 0, y: 0 }, + prerequisites: ['web.http'], + challenges: [], + difficulty: 2, + }, + { + id: 'web.client', + discipline: 'web', + branch: 'web', + title: 'Cliente Web', + description: 'Construye un cliente HTTP — conecta, envía request, parsea response', + icon: '💻', + position: { x: 0, y: 0 }, + prerequisites: ['web.http', 'net.sockets'], + challenges: [], + difficulty: 4, + }, + { + id: 'web.final', + discipline: 'web', + branch: 'web', + title: '🏆 Cliente ↔ Servidor', + description: 'Tu servidor web sirve una página. Tu cliente la pide. Se comunican por TCP. Todo hecho por ti.', + icon: '🏆', + position: { x: 0, y: 0 }, + prerequisites: ['web.server', 'web.client', 'web.html-css'], + challenges: [], + difficulty: 5, + }, ]; export const skillEdges: SkillEdge[] = skillNodes @@ -237,7 +1043,7 @@ export function getNodeStatus( const node = getNodeById(nodeId); if (!node) return 'locked'; - const allChallengesCompleted = node.challenges.every((c) => + const allChallengesCompleted = node.challenges.length > 0 && node.challenges.every((c) => completedChallengeIds.includes(`${nodeId}/${c}`) ); if (allChallengesCompleted) return 'completed'; diff --git a/src/lib/challenge-engine/verifier.ts b/src/lib/challenge-engine/verifier.ts index 1c86172..b15f1d2 100644 --- a/src/lib/challenge-engine/verifier.ts +++ b/src/lib/challenge-engine/verifier.ts @@ -1,4 +1,8 @@ import { Challenge, VerificationResult } from '@/types/challenge'; +import { CircuitBuilderContent, CircuitState } from '@/types/circuit'; +import { ElectronicsContent, ElectronicCircuitState } from '@/types/electronics'; +import { simulateCircuit } from '@/components/workbench/modules/circuit-builder/simulateCircuit'; +import { simulateElectronics } from '@/components/workbench/modules/electronics/simulateElectronics'; export function verifyAnswer( challenge: Challenge, @@ -11,6 +15,10 @@ export function verifyAnswer( return verifyMathInput(content, userAnswer, challenge.xpReward); case 'multiple-choice': return verifyMultipleChoice(content, userAnswer, challenge.xpReward); + case 'circuit-builder': + return verifyCircuit(content, userAnswer as string, challenge.xpReward); + case 'electronics-lab': + return verifyElectronics(content, userAnswer as string, challenge.xpReward); default: return { correct: false, message: 'Tipo de reto no soportado', xpEarned: 0 }; } @@ -53,3 +61,107 @@ function verifyMultipleChoice( return { correct: false, message: 'Incorrecto. Inténtalo de nuevo.', xpEarned: 0 }; } + +function verifyCircuit( + content: CircuitBuilderContent, + userAnswer: string, + xpReward: number +): VerificationResult { + let circuit: CircuitState; + try { + circuit = JSON.parse(userAnswer); + } catch { + return { correct: false, message: 'Circuito inválido', xpEarned: 0 }; + } + + if (circuit.gates.length === 0) { + return { correct: false, message: 'Coloca al menos una puerta lógica', xpEarned: 0 }; + } + + let passedRows = 0; + const totalRows = content.truthTable.length; + + for (const row of content.truthTable) { + const result = simulateCircuit(circuit, row.inputs); + if (typeof result === 'string') { + return { correct: false, message: result, xpEarned: 0 }; + } + if (result.length !== row.outputs.length) { + return { correct: false, message: `Se esperan ${row.outputs.length} salida(s), tu circuito produce ${result.length}`, xpEarned: 0 }; + } + const match = result.every((v, i) => v === row.outputs[i]); + if (match) passedRows++; + } + + if (passedRows === totalRows) { + let bonus = ''; + if (content.maxGates && circuit.gates.length <= content.maxGates) { + bonus = ` (¡con solo ${circuit.gates.length} puerta${circuit.gates.length === 1 ? '' : 's'}!)`; + } + return { correct: true, message: `¡Circuito correcto! 🎉${bonus}`, xpEarned: xpReward }; + } + + return { + correct: false, + message: `Tu circuito pasa ${passedRows} de ${totalRows} combinaciones. Revisa las conexiones.`, + xpEarned: 0, + }; +} + +function verifyElectronics( + content: ElectronicsContent, + userAnswer: string, + xpReward: number +): VerificationResult { + let circuit: ElectronicCircuitState; + try { + circuit = JSON.parse(userAnswer); + } catch { + return { correct: false, message: 'Circuito inválido', xpEarned: 0 }; + } + + if (circuit.components.length === 0) { + return { correct: false, message: 'Coloca al menos un componente', xpEarned: 0 }; + } + + const result = simulateElectronics(circuit); + if (!result.success) { + return { correct: false, message: result.error ?? 'Error de simulación', xpEarned: 0 }; + } + + // Collect all meter readings by type + const voltmeterReadings: number[] = []; + const ammeterReadings: number[] = []; + for (const [, reading] of result.meterReadings) { + if (reading.unit === 'V') voltmeterReadings.push(reading.value); + if (reading.unit === 'mA') ammeterReadings.push(reading.value); + } + + let passed = 0; + const messages: string[] = []; + + for (const probe of content.probes) { + const readings = probe.type === 'voltmeter' ? voltmeterReadings : ammeterReadings; + const instrumentName = probe.type === 'voltmeter' ? 'voltímetro' : 'amperímetro'; + + if (readings.length === 0) { + messages.push(`Coloca un ${instrumentName} para medir: ${probe.label}`); + continue; + } + + // Check if ANY meter of the right type reads the expected value + const match = readings.some((r) => Math.abs(r - probe.expected) <= probe.tolerance); + if (match) { + passed++; + } else { + messages.push(`${probe.label}: esperado ${probe.expected}${probe.unit}, tu ${instrumentName} lee ${readings.map(r => r.toFixed(1) + probe.unit).join(', ')}`); + } + } + + if (passed === content.probes.length) { + return { correct: true, message: '¡Circuito correcto! 🎉', xpEarned: xpReward }; + } + + const msg = messages.length > 0 ? messages[0] : `${passed} de ${content.probes.length} mediciones correctas.`; + return { correct: false, message: msg, xpEarned: 0 }; +} diff --git a/src/stores/useProgressStore.ts b/src/stores/useProgressStore.ts index 4b828b2..d5f9e76 100644 --- a/src/stores/useProgressStore.ts +++ b/src/stores/useProgressStore.ts @@ -126,7 +126,7 @@ export const useProgressStore = create()( const state = get(); const completedNodeIds: string[] = []; for (const node of skillNodes) { - const allDone = node.challenges.every( + const allDone = node.challenges.length > 0 && node.challenges.every( (cId) => state.completedChallenges[`${node.id}/${cId}`] ); if (allDone) completedNodeIds.push(node.id); @@ -141,7 +141,7 @@ export const useProgressStore = create()( const completedNodeIds = get().getCompletedNodeIds(); - const allDone = node.challenges.every( + const allDone = node.challenges.length > 0 && node.challenges.every( (cId) => state.completedChallenges[`${node.id}/${cId}`] ); if (allDone) return 'completed'; diff --git a/src/types/challenge.ts b/src/types/challenge.ts index f6e678d..6a3013a 100644 --- a/src/types/challenge.ts +++ b/src/types/challenge.ts @@ -3,6 +3,7 @@ export type WorkbenchType = | 'multiple-choice' | 'code-editor' | 'circuit-builder' + | 'electronics-lab' | 'physics-sim' | 'signal-playground' | 'graph-plotter'; @@ -24,7 +25,10 @@ export interface MultipleChoiceContent { correctIndex: number; } -export type ChallengeContent = MathInputContent | MultipleChoiceContent; +import { CircuitBuilderContent } from './circuit'; +import { ElectronicsContent } from './electronics'; + +export type ChallengeContent = MathInputContent | MultipleChoiceContent | CircuitBuilderContent | ElectronicsContent; export interface Challenge { id: string; diff --git a/src/types/circuit.ts b/src/types/circuit.ts new file mode 100644 index 0000000..4e42422 --- /dev/null +++ b/src/types/circuit.ts @@ -0,0 +1,51 @@ +export type GateType = 'AND' | 'OR' | 'NOT' | 'NAND' | 'NOR' | 'XOR' | 'XNOR'; + +export interface PlacedGate { + id: string; + type: GateType; + x: number; + y: number; +} + +export interface Wire { + id: string; + from: string; // port id: "input-0", "gate-1:out", "gate-2:out" + to: string; // port id: "output-0", "gate-1:in-0", "gate-1:in-1" +} + +export interface CircuitState { + gates: PlacedGate[]; + wires: Wire[]; +} + +export interface TruthTableRow { + inputs: boolean[]; + outputs: boolean[]; +} + +export interface CircuitBuilderContent { + type: 'circuit-builder'; + availableGates: GateType[]; + inputLabels: string[]; + outputLabels: string[]; + truthTable: TruthTableRow[]; + maxGates?: number; +} + +// Gate metadata +export function gateInputCount(type: GateType): number { + return type === 'NOT' ? 1 : 2; +} + +export function evaluateGate(type: GateType, inputs: boolean[]): boolean { + const [a, b] = inputs; + switch (type) { + case 'AND': return a && b; + case 'OR': return a || b; + case 'NOT': return !a; + case 'NAND': return !(a && b); + case 'NOR': return !(a || b); + case 'XOR': return a !== b; + case 'XNOR': return a === b; + } +} diff --git a/src/types/electronics.ts b/src/types/electronics.ts new file mode 100644 index 0000000..b00ff32 --- /dev/null +++ b/src/types/electronics.ts @@ -0,0 +1,91 @@ +// === Component types for the electronics simulator === + +export type ElectronicComponentType = + | 'voltage-source' // DC voltage source + | 'resistor' // Resistor (Ohms) + | 'capacitor' // Capacitor (Farads) — in DC steady state acts as open circuit + | 'led' // LED (simplified: threshold voltage + series resistance) + | 'switch' // Manual on/off switch + | 'nmos' // N-channel MOSFET + | 'pmos' // P-channel MOSFET + | 'ground' // Ground reference + | 'voltmeter' // Measures voltage between two points + | 'ammeter'; // Measures current through a branch + +export interface ElectronicComponent { + id: string; + type: ElectronicComponentType; + x: number; + y: number; + rotation: 0 | 90 | 180 | 270; + value?: number; // resistance in Ohms, voltage in V + label?: string; +} + +export interface ElectronicWire { + id: string; + from: string; // "comp-1:a" or "comp-1:b" (terminal a/b) + to: string; +} + +export interface ElectronicCircuitState { + components: ElectronicComponent[]; + wires: ElectronicWire[]; +} + +// === Simulation result === +export interface SimulationResult { + success: boolean; + error?: string; + nodeVoltages: Map; // terminalId -> voltage + branchCurrents: Map; // componentId -> current through it + meterReadings: Map; // meterId -> reading +} + +// === Probe: what we check in a challenge === +export interface ProbeCheck { + type: 'voltmeter' | 'ammeter'; // must use the corresponding instrument + expected: number; // expected reading + tolerance: number; // allowed error + label: string; // "Voltaje en R2" etc + unit: string; // 'V' or 'mA' +} + +export interface ElectronicsContent { + type: 'electronics-lab'; + availableComponents: ElectronicComponentType[]; + probes: ProbeCheck[]; + preplacedComponents?: ElectronicComponent[]; + preplacedWires?: ElectronicWire[]; +} + +// === Component metadata === +export function getTerminals(type: ElectronicComponentType): string[] { + switch (type) { + case 'voltage-source': return ['pos', 'neg']; + case 'resistor': return ['a', 'b']; + case 'capacitor': return ['a', 'b']; + case 'led': return ['anode', 'cathode']; + case 'switch': return ['a', 'b']; + case 'ground': return ['gnd']; + case 'nmos': return ['gate', 'drain', 'source']; + case 'pmos': return ['gate', 'drain', 'source']; + case 'voltmeter': return ['pos', 'neg']; + case 'ammeter': return ['a', 'b']; + } +} + +export function componentLabel(type: ElectronicComponentType): string { + switch (type) { + case 'voltage-source': return 'Fuente V'; + case 'resistor': return 'Resistencia'; + case 'capacitor': return 'Condensador'; + case 'led': return 'LED'; + case 'switch': return 'Interruptor'; + case 'ground': return 'GND'; + case 'nmos': return 'NMOS'; + case 'pmos': return 'PMOS'; + case 'voltmeter': return 'Voltímetro'; + case 'ammeter': return 'Amperímetro'; + } +} diff --git a/src/types/skill-tree.ts b/src/types/skill-tree.ts index 7a0357a..48b8414 100644 --- a/src/types/skill-tree.ts +++ b/src/types/skill-tree.ts @@ -4,8 +4,20 @@ export type Discipline = | 'programming' | 'physics' | 'electronics' - | 'cryptography' + | 'digital-circuits' + | 'sequential-circuits' + | 'computer-architecture' + | 'low-level-programming' | 'signal-processing' + | 'audio-synthesis' + | 'graphics' + | 'game-dev' + | 'extended-architecture' + | 'operating-systems' + | 'high-level-programming' + | 'networking' + | 'web' + | 'cryptography' | 'statistics'; export interface SkillNode { diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..aa2572a --- /dev/null +++ b/todo.md @@ -0,0 +1,37 @@ +# MathTree — Roadmap + +## En progreso +- [ ] Nuevos nodos: Puertas Lógicas, Circuitos Combinacionales +- [ ] Workbench: Circuit Builder (drag-and-drop de puertas, cables, verificación por tabla de verdad) + +## Contenido +- [ ] Generación procedural de retos (sumas, restas, etc. aleatorias con dificultad escalada) +- [ ] Más retos por nodo (objetivo: 8-10 por nodo mínimo) +- [ ] Explicaciones "Aprende primero" en todos los retos, no solo los primeros +- [ ] Nodos de Geometría (ángulos, áreas, trigonometría) +- [ ] Nodos de Estadística (media, mediana, probabilidad) +- [ ] Nodos de Programación (pseudocódigo, variables, bucles) + +## Nuevos Workbenches +- [ ] Circuit Builder — puertas lógicas, cables, tablas de verdad +- [ ] Code Editor — Monaco editor con test cases para nodos de programación +- [ ] Graph/Plotter — plotting interactivo estilo Desmos para funciones y geometría +- [ ] Physics Sim — canvas 2D con motor de física para cinemática y balística + +## UX / Pulido +- [ ] Animaciones al desbloquear nodos, ganar XP, subir de nivel +- [ ] Sonidos de feedback (correcto/incorrecto/desbloqueo) +- [ ] Landing page +- [ ] Responsive / mobile improvements +- [ ] Dark/light mode toggle + +## Persistencia +- [ ] Supabase auth (email/password) +- [ ] Sync progreso a la nube +- [ ] Página de perfil con avatar y username + +## Futuro +- [ ] Features sociales (compartir soluciones, duelos) +- [ ] Leaderboards semanales +- [ ] Integrar logic-gates como módulo embebido del circuit builder +- [ ] Integrar reaktor como módulo embebido del signal playground